@synkro-sh/cli 1.4.76 → 1.4.78

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bootstrap.js CHANGED
@@ -6277,1081 +6277,7 @@ function writeHookScripts() {
6277
6277
  writeFileSync7(commonBashScriptPath, SYNKRO_COMMON_SCRIPT, "utf-8");
6278
6278
  writeFileSync7(cursorBashJudgePath, CURSOR_BASH_JUDGE_TS, "utf-8");
6279
6279
  writeFileSync7(cursorEditCapturePath, CURSOR_EDIT_CAPTURE_TS, "utf-8");
6280
- writeFileSync7(mcpLocalServerPath, `#!/usr/bin/env bun
6281
- /**
6282
- * Local MCP guardrails server \u2014 runs on port 8931, stores rules in ~/.synkro/rules.json.
6283
- * JSON-RPC 2.0 over HTTP, same protocol as the cloud MCP server.
6284
- * Bearer token auth (file-based shared secret), localhost only, no embedding API, no Inngest.
6285
- */
6286
- import { existsSync, readFileSync, writeFileSync, renameSync, appendFileSync, mkdirSync } from 'node:fs';
6287
- import { homedir } from 'node:os';
6288
- import { join } from 'node:path';
6289
-
6290
- const PORT = parseInt(process.env.SYNKRO_MCP_PORT || '8931', 10);
6291
- const HOME = homedir();
6292
- const RULES_PATH = join(HOME, '.synkro', 'rules.json');
6293
- const TELEMETRY_PATH = join(HOME, '.synkro', 'telemetry.jsonl');
6294
- const JWT_TOKEN_PATH = join(HOME, '.synkro', '.mcp-jwt');
6295
-
6296
- // Synkro-signed long-lived JWT \u2014 minted during \`synkro install\`, required on all POST requests.
6297
- // If missing, the server still starts (for GET health checks) but rejects all tool calls.
6298
- let SERVER_TOKEN = '';
6299
- try { SERVER_TOKEN = readFileSync(JWT_TOKEN_PATH, 'utf-8').trim(); } catch {}
6300
- if (!SERVER_TOKEN) console.warn('[synkro] \u26A0 No MCP JWT found \u2014 run \`synkro install\` to authenticate.');
6301
- const MAX_BODY_BYTES = 1_048_576;
6302
-
6303
- let _writeLock: Promise<void> = Promise.resolve();
6304
- function serialized<T>(fn: () => T | Promise<T>): Promise<T> {
6305
- let release: () => void;
6306
- const next = new Promise<void>(r => { release = r; });
6307
- const prev = _writeLock;
6308
- _writeLock = next;
6309
- return prev.then(() => fn()).finally(() => release!());
6310
- }
6311
-
6312
- // \u2500\u2500\u2500 Storage \u2500\u2500\u2500
6313
-
6314
- interface Rule {
6315
- rule_id: string;
6316
- text: string;
6317
- category: string;
6318
- severity: string;
6319
- mode: string;
6320
- hook_stage: string;
6321
- scope: string;
6322
- }
6323
-
6324
- interface Policy {
6325
- id: string;
6326
- name: string;
6327
- rules: Rule[];
6328
- ruleCount: number;
6329
- scopeOwner: string;
6330
- isActive: boolean;
6331
- }
6332
-
6333
- interface ScanExemption {
6334
- path: string;
6335
- cwe_id: string;
6336
- reason?: string;
6337
- }
6338
-
6339
- interface RulesFile {
6340
- policies: Policy[];
6341
- config: { silent: boolean; activePolicyId: string };
6342
- scanExemptions: ScanExemption[];
6343
- }
6344
-
6345
- function readRules(): RulesFile {
6346
- if (!existsSync(RULES_PATH)) {
6347
- return {
6348
- policies: [{
6349
- id: 'local-policy',
6350
- name: 'My Rules',
6351
- rules: [],
6352
- ruleCount: 0,
6353
- scopeOwner: 'user',
6354
- isActive: true,
6355
- }],
6356
- config: { silent: false, activePolicyId: 'local-policy' },
6357
- scanExemptions: [],
6358
- };
6359
- }
6360
- try {
6361
- return JSON.parse(readFileSync(RULES_PATH, 'utf-8'));
6362
- } catch {
6363
- return {
6364
- policies: [{ id: 'local-policy', name: 'My Rules', rules: [], ruleCount: 0, scopeOwner: 'user', isActive: true }],
6365
- config: { silent: false, activePolicyId: 'local-policy' },
6366
- scanExemptions: [],
6367
- };
6368
- }
6369
- }
6370
-
6371
- function writeRules(data: RulesFile): void {
6372
- for (const p of data.policies) p.ruleCount = p.rules.length;
6373
- mkdirSync(join(HOME, '.synkro'), { recursive: true });
6374
- const tmp = RULES_PATH + '.tmp';
6375
- writeFileSync(tmp, JSON.stringify(data, null, 2) + '\\n', 'utf-8');
6376
- renameSync(tmp, RULES_PATH);
6377
- }
6378
-
6379
- function emitRuleSync(data: RulesFile): void {
6380
- const active = data.policies.find(p => p.id === data.config.activePolicyId) || data.policies[0];
6381
- const event = {
6382
- capture_type: 'rule_sync',
6383
- policy_id: active?.id || 'local-policy',
6384
- policy_name: active?.name || 'My Rules',
6385
- rules: active?.rules || [],
6386
- rule_count: active?.ruleCount || 0,
6387
- scan_exemptions: data.scanExemptions,
6388
- silent: data.config.silent,
6389
- _ts: new Date().toISOString(),
6390
- };
6391
- try {
6392
- appendFileSync(TELEMETRY_PATH, JSON.stringify(event) + '\\n', 'utf-8');
6393
- } catch {}
6394
- }
6395
-
6396
- function genId(): string {
6397
- return \`r_\${Date.now()}_\${Math.random().toString(36).slice(2, 8)}\`;
6398
- }
6399
-
6400
- function getActivePolicy(data: RulesFile): Policy {
6401
- return data.policies.find(p => p.id === data.config.activePolicyId) || data.policies[0];
6402
- }
6403
-
6404
- function findOrCreatePolicy(data: RulesFile, name: string): Policy {
6405
- const existing = data.policies.find(p => p.name.toLowerCase() === name.toLowerCase());
6406
- if (existing) return existing;
6407
- const p: Policy = {
6408
- id: \`policy_\${Date.now()}_\${Math.random().toString(36).slice(2, 6)}\`,
6409
- name,
6410
- rules: [],
6411
- ruleCount: 0,
6412
- scopeOwner: 'user',
6413
- isActive: true,
6414
- };
6415
- data.policies.push(p);
6416
- return p;
6417
- }
6418
-
6419
- function getAllRules(data: RulesFile): Array<Rule & { policyName: string; policyId: string }> {
6420
- const all: Array<Rule & { policyName: string; policyId: string }> = [];
6421
- for (const p of data.policies) {
6422
- if (!p.isActive) continue;
6423
- for (const r of p.rules) {
6424
- all.push({ ...r, policyName: p.name, policyId: p.id });
6425
- }
6426
- }
6427
- return all;
6428
- }
6429
-
6430
- // \u2500\u2500\u2500 Keyword Search \u2500\u2500\u2500
6431
-
6432
- const 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']);
6433
-
6434
- function tokenize(text: string): string[] {
6435
- return text.toLowerCase().replace(/[^a-z0-9_-]/g, ' ').split(/\\s+/).filter(t => t.length > 1 && !STOPWORDS.has(t));
6436
- }
6437
-
6438
- function keywordSearch(query: string, rules: Array<Rule & { policyName: string; policyId: string }>, topK: number): any[] {
6439
- const qTokens = tokenize(query);
6440
- if (qTokens.length === 0) return rules.slice(0, topK);
6441
-
6442
- const scored = rules.map(r => {
6443
- const rTokens = new Set(tokenize(\`\${r.text} \${r.category} \${r.severity}\`));
6444
- const overlap = qTokens.filter(t => rTokens.has(t) || [...rTokens].some(rt => rt.includes(t) || t.includes(rt))).length;
6445
- return { rule: r, score: overlap / qTokens.length };
6446
- });
6447
-
6448
- scored.sort((a, b) => b.score - a.score);
6449
- const results = scored.filter(s => s.score > 0).slice(0, topK);
6450
- if (results.length === 0) return rules.slice(0, topK);
6451
-
6452
- return results.map(s => ({
6453
- rule_id: s.rule.rule_id,
6454
- text: s.rule.text,
6455
- category: s.rule.category,
6456
- severity: s.rule.severity,
6457
- mode: s.rule.mode,
6458
- hook_stage: s.rule.hook_stage,
6459
- scope: s.rule.scope,
6460
- pack_name: s.rule.policyName,
6461
- score: Math.round(s.score * 100) / 100,
6462
- }));
6463
- }
6464
-
6465
- // \u2500\u2500\u2500 Tool Handlers \u2500\u2500\u2500
6466
-
6467
- function handleGetGuardrails(args: any): any {
6468
- const data = readRules();
6469
- const all = getAllRules(data);
6470
- const topK = Math.min(args.top_k || 8, 25);
6471
- let filtered = all;
6472
- if (args.category) filtered = filtered.filter(r => r.category === args.category);
6473
- const results = keywordSearch(args.query || '', filtered, topK);
6474
- return { rules: results, total: results.length, query: args.query };
6475
- }
6476
-
6477
- function handleCreateGuardrail(args: any): any {
6478
- const data = readRules();
6479
- const policy = args.ruleset ? findOrCreatePolicy(data, args.ruleset) : getActivePolicy(data);
6480
- const rule: Rule = {
6481
- rule_id: genId(),
6482
- text: args.text,
6483
- category: args.category || 'custom',
6484
- severity: args.severity || 'medium',
6485
- mode: args.mode || 'audit',
6486
- hook_stage: args.hook_stage || 'both',
6487
- scope: args.scope || 'user',
6488
- };
6489
- policy.rules.push(rule);
6490
- writeRules(data);
6491
- emitRuleSync(data);
6492
- return { created: true, rule_id: rule.rule_id, text: rule.text, pack_name: policy.name, total_rules: policy.rules.length };
6493
- }
6494
-
6495
- function handleBulkCreateGuardrails(args: any): any {
6496
- const data = readRules();
6497
- const policy = args.ruleset ? findOrCreatePolicy(data, args.ruleset) : getActivePolicy(data);
6498
- const created: any[] = [];
6499
- for (const r of args.rules || []) {
6500
- const rule: Rule = {
6501
- rule_id: genId(),
6502
- text: r.text,
6503
- category: r.category || 'custom',
6504
- severity: r.severity || 'medium',
6505
- mode: r.mode || 'audit',
6506
- hook_stage: r.hook_stage || 'both',
6507
- scope: args.scope || 'user',
6508
- };
6509
- policy.rules.push(rule);
6510
- created.push({ rule_id: rule.rule_id, text: rule.text });
6511
- }
6512
- writeRules(data);
6513
- emitRuleSync(data);
6514
- return { created: created.length, rules: created, pack_name: policy.name, total_rules: policy.rules.length };
6515
- }
6516
-
6517
- function handleUpdateGuardrail(args: any): any {
6518
- const data = readRules();
6519
- const needle = (args.rule_text || '').toLowerCase();
6520
- for (const p of data.policies) {
6521
- for (const r of p.rules) {
6522
- if (r.text.toLowerCase().includes(needle)) {
6523
- if (args.text) r.text = args.text;
6524
- if (args.category) r.category = args.category;
6525
- if (args.severity) r.severity = args.severity;
6526
- if (args.mode) r.mode = args.mode;
6527
- if (args.hook_stage) r.hook_stage = args.hook_stage;
6528
- writeRules(data);
6529
- emitRuleSync(data);
6530
- return { updated: true, rule_id: r.rule_id, text: r.text };
6531
- }
6532
- }
6533
- }
6534
- return { updated: false, error: \`No rule found matching "\${args.rule_text}"\` };
6535
- }
6536
-
6537
- function handleDeleteGuardrail(args: any): any {
6538
- const data = readRules();
6539
- const needle = (args.rule_text || '').toLowerCase();
6540
- for (const p of data.policies) {
6541
- const idx = p.rules.findIndex(r => r.text.toLowerCase().includes(needle));
6542
- if (idx !== -1) {
6543
- const removed = p.rules.splice(idx, 1)[0];
6544
- writeRules(data);
6545
- emitRuleSync(data);
6546
- return { deleted: true, rule_id: removed.rule_id, text: removed.text };
6547
- }
6548
- }
6549
- return { deleted: false, error: \`No rule found matching "\${args.rule_text}"\` };
6550
- }
6551
-
6552
- function handleListGuardrails(args: any): any {
6553
- const data = readRules();
6554
- let all = getAllRules(data);
6555
- if (args.category) all = all.filter(r => r.category === args.category);
6556
- if (args.severity) all = all.filter(r => r.severity === args.severity);
6557
- if (args.mode) all = all.filter(r => r.mode === args.mode);
6558
- if (args.hook_stage) all = all.filter(r => r.hook_stage === args.hook_stage);
6559
- if (args.pack_name) {
6560
- const pn = args.pack_name.toLowerCase();
6561
- all = all.filter(r => r.policyName.toLowerCase().includes(pn));
6562
- }
6563
- return {
6564
- rules: all.map(r => ({
6565
- rule_id: r.rule_id,
6566
- text: r.text,
6567
- category: r.category,
6568
- severity: r.severity,
6569
- mode: r.mode,
6570
- hook_stage: r.hook_stage,
6571
- scope: r.scope,
6572
- pack_name: r.policyName,
6573
- })),
6574
- total: all.length,
6575
- };
6576
- }
6577
-
6578
- function handleSwapRuleset(args: any): any {
6579
- const data = readRules();
6580
- const name = args.policy_name || '';
6581
- if (name.toLowerCase() === 'all') {
6582
- data.config.activePolicyId = data.policies[0]?.id || 'local-policy';
6583
- writeRules(data);
6584
- return { swapped: true, active: 'all' };
6585
- }
6586
- const match = data.policies.find(p => p.name.toLowerCase().includes(name.toLowerCase()));
6587
- if (!match) return { swapped: false, error: \`No ruleset found matching "\${name}"\` };
6588
- data.config.activePolicyId = match.id;
6589
- writeRules(data);
6590
- return { swapped: true, active: match.name };
6591
- }
6592
-
6593
- function handleToggleSilentMode(args: any): any {
6594
- const data = readRules();
6595
- data.config.silent = args.enabled === true;
6596
- writeRules(data);
6597
- emitRuleSync(data);
6598
- return { silent: data.config.silent };
6599
- }
6600
-
6601
- async function handleScanDependencies(args: any): Promise<any> {
6602
- const manifests = args.manifests || [];
6603
- if (manifests.length === 0) return { findings: [], summary: null };
6604
-
6605
- const packages: Array<{ name: string; version: string; ecosystem: string }> = [];
6606
- for (const m of manifests) {
6607
- const fp: string = m.file_path || '';
6608
- const content: string = m.content || '';
6609
- try {
6610
- if (fp.endsWith('package.json')) {
6611
- const pkg = JSON.parse(content);
6612
- for (const [name, ver] of Object.entries({ ...pkg.dependencies, ...pkg.devDependencies })) {
6613
- packages.push({ name, version: String(ver).replace(/^[\\^~>=<]*/g, ''), ecosystem: 'npm' });
6614
- }
6615
- } else if (fp.endsWith('requirements.txt') || fp.match(/requirements.*\\.txt$/)) {
6616
- for (const line of content.split('\\n')) {
6617
- const m = line.trim().match(/^([a-zA-Z0-9_-]+)==(.+)/);
6618
- if (m) packages.push({ name: m[1], version: m[2], ecosystem: 'PyPI' });
6619
- }
6620
- } else if (fp.endsWith('go.mod')) {
6621
- for (const line of content.split('\\n')) {
6622
- const m = line.trim().match(/^\\t?([^\\s]+)\\s+v([^\\s]+)/);
6623
- if (m) packages.push({ name: m[1], version: m[2], ecosystem: 'Go' });
6624
- }
6625
- } else if (fp.endsWith('Cargo.toml')) {
6626
- for (const line of content.split('\\n')) {
6627
- const m = line.trim().match(/^([a-zA-Z0-9_-]+)\\s*=\\s*"([^"]+)"/);
6628
- if (m && !['name', 'version', 'edition', 'authors', 'description', 'license', 'repository'].includes(m[1])) {
6629
- packages.push({ name: m[1], version: m[2], ecosystem: 'crates.io' });
6630
- }
6631
- }
6632
- }
6633
- } catch {}
6634
- }
6635
-
6636
- if (packages.length === 0) return { findings: [], summary: null };
6637
-
6638
- const capped = packages.slice(0, 50);
6639
- const queries = capped.map(p => ({ package: { name: p.name, ecosystem: p.ecosystem }, version: p.version }));
6640
-
6641
- try {
6642
- const resp = await fetch('https://api.osv.dev/v1/querybatch', {
6643
- method: 'POST',
6644
- headers: { 'Content-Type': 'application/json' },
6645
- body: JSON.stringify({ queries }),
6646
- signal: AbortSignal.timeout(10000),
6647
- });
6648
- if (!resp.ok) return { findings: [], summary: 'OSV query failed' };
6649
- const data = await resp.json() as { results: Array<{ vulns?: any[] }> };
6650
-
6651
- const findings: any[] = [];
6652
- for (let i = 0; i < data.results.length; i++) {
6653
- for (const vuln of data.results[i].vulns || []) {
6654
- findings.push({
6655
- id: vuln.id,
6656
- package: capped[i].name,
6657
- version: capped[i].version,
6658
- ecosystem: capped[i].ecosystem,
6659
- summary: vuln.summary || 'No description',
6660
- aliases: vuln.aliases || [],
6661
- severity: vuln.database_specific?.severity || 'unknown',
6662
- });
6663
- }
6664
- }
6665
- return { findings, summary: findings.length > 0 ? \`\${findings.length} vulnerabilities found\` : null };
6666
- } catch {
6667
- return { findings: [], summary: 'OSV query timed out' };
6668
- }
6669
- }
6670
-
6671
- function handleExemptPath(args: any): any {
6672
- const data = readRules();
6673
- const existing = data.scanExemptions.find(e => e.path === args.path && e.cwe_id.toUpperCase() === (args.cwe_id || '').toUpperCase());
6674
- if (existing) return { exempted: true, already_existed: true, path: args.path, cwe_id: args.cwe_id };
6675
-
6676
- data.scanExemptions.push({ path: args.path, cwe_id: (args.cwe_id || '').toUpperCase(), reason: args.reason });
6677
- writeRules(data);
6678
- emitRuleSync(data);
6679
- return { exempted: true, path: args.path, cwe_id: args.cwe_id, total_exemptions: data.scanExemptions.length };
6680
- }
6681
-
6682
- function handleRemoveExemption(args: any): any {
6683
- const data = readRules();
6684
- const idx = data.scanExemptions.findIndex(e => e.path === args.path && e.cwe_id.toUpperCase() === (args.cwe_id || '').toUpperCase());
6685
- if (idx === -1) return { removed: false, error: \`No exemption found for path="\${args.path}" cwe_id="\${args.cwe_id}"\` };
6686
- data.scanExemptions.splice(idx, 1);
6687
- writeRules(data);
6688
- emitRuleSync(data);
6689
- return { removed: true, path: args.path, cwe_id: args.cwe_id };
6690
- }
6691
-
6692
- function handleListExemptions(): any {
6693
- const data = readRules();
6694
- return { exemptions: data.scanExemptions, total: data.scanExemptions.length };
6695
- }
6696
-
6697
- // \u2500\u2500\u2500 Findings \u2500\u2500\u2500
6698
-
6699
- const CONFIG_PATH = join(HOME, '.synkro', 'config.json');
6700
- const JWT_PATH = join(HOME, '.synkro', '.jwt');
6701
-
6702
- interface Finding {
6703
- id: string;
6704
- session_id: string;
6705
- file_path: string;
6706
- finding_type: string;
6707
- finding_id: string;
6708
- severity: string;
6709
- status: string;
6710
- detail?: string;
6711
- package_name?: string;
6712
- package_version?: string;
6713
- fixed_version?: string;
6714
- created_at: string;
6715
- resolved_at?: string;
6716
- }
6717
-
6718
- function getCloudConfig(): { apiUrl: string; jwt: string } | null {
6719
- try {
6720
- const jwt = readFileSync(JWT_PATH, 'utf-8').trim();
6721
- if (!jwt) return null;
6722
- let apiUrl = process.env.SYNKRO_API_URL || '';
6723
- if (!apiUrl) {
6724
- try {
6725
- const cfg = JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'));
6726
- apiUrl = cfg.api_url || cfg.apiUrl || '';
6727
- } catch {}
6728
- }
6729
- if (!apiUrl) apiUrl = 'https://api.synkro.sh';
6730
- return { apiUrl, jwt };
6731
- } catch {
6732
- return null;
6733
- }
6734
- }
6735
-
6736
- function readLocalFindings(): Finding[] {
6737
- if (!existsSync(TELEMETRY_PATH)) return [];
6738
- let lines: string[];
6739
- try {
6740
- lines = readFileSync(TELEMETRY_PATH, 'utf-8').split('\\n').filter(Boolean);
6741
- } catch { return []; }
6742
- const findingMap = new Map<string, Finding>();
6743
-
6744
- for (const line of lines) {
6745
- try {
6746
- const event = JSON.parse(line);
6747
- if (event.capture_type !== 'scan_finding') continue;
6748
- const key = \`\${event.file_path}:\${event.finding_id}\`;
6749
- const existing = findingMap.get(key);
6750
- const ts = event._ts || event.created_at || '';
6751
-
6752
- findingMap.set(key, {
6753
- id: event.id || existing?.id || \`sf_\${event.session_id}_\${event.finding_id}_\${Date.now()}\`,
6754
- session_id: event.session_id || existing?.session_id || '',
6755
- file_path: event.file_path,
6756
- finding_type: event.finding_type,
6757
- finding_id: event.finding_id,
6758
- severity: event.severity || 'unknown',
6759
- status: event.status || 'open',
6760
- detail: event.detail || existing?.detail,
6761
- package_name: event.package_name || existing?.package_name,
6762
- package_version: event.package_version || existing?.package_version,
6763
- fixed_version: event.fixed_version || existing?.fixed_version,
6764
- created_at: existing?.created_at || ts,
6765
- resolved_at: event.status === 'resolved' ? ts : existing?.resolved_at,
6766
- });
6767
- } catch {}
6768
- }
6769
-
6770
- return Array.from(findingMap.values());
6771
- }
6772
-
6773
- async function fetchCloudFindings(params: Record<string, string>): Promise<Finding[] | null> {
6774
- const cloud = getCloudConfig();
6775
- if (!cloud) return null;
6776
- try {
6777
- const qs = new URLSearchParams(params).toString();
6778
- const resp = await fetch(\`\${cloud.apiUrl}/api/v1/scan-findings?\${qs}\`, {
6779
- headers: { Authorization: \`Bearer \${cloud.jwt}\` },
6780
- signal: AbortSignal.timeout(5000),
6781
- });
6782
- if (!resp.ok) return null;
6783
- const data = await resp.json() as any;
6784
- return data.findings || data.data || [];
6785
- } catch {
6786
- return null;
6787
- }
6788
- }
6789
-
6790
- function dispatchResolution(finding: Finding): void {
6791
- const cloud = getCloudConfig();
6792
- if (!cloud) return;
6793
- fetch(\`\${cloud.apiUrl}/api/v1/hook/finding\`, {
6794
- method: 'POST',
6795
- headers: { Authorization: \`Bearer \${cloud.jwt}\`, 'Content-Type': 'application/json' },
6796
- body: JSON.stringify({
6797
- session_id: finding.session_id,
6798
- file_path: finding.file_path,
6799
- finding_type: finding.finding_type,
6800
- finding_id: finding.finding_id,
6801
- severity: finding.severity,
6802
- status: 'resolved',
6803
- }),
6804
- signal: AbortSignal.timeout(3000),
6805
- }).catch(() => {});
6806
- }
6807
-
6808
- async function handleListFindings(args: any): Promise<any> {
6809
- const cloudParams: Record<string, string> = {};
6810
- if (args.status) cloudParams.status = args.status;
6811
- if (args.finding_type) cloudParams.finding_type = args.finding_type;
6812
- if (args.severity) cloudParams.severity = args.severity;
6813
- if (args.file_path) cloudParams.file_path = args.file_path;
6814
- if (args.limit) cloudParams.limit = String(args.limit);
6815
-
6816
- const cloudFindings = await fetchCloudFindings(cloudParams);
6817
-
6818
- let findings: Finding[];
6819
- let source: string;
6820
- if (cloudFindings) {
6821
- findings = cloudFindings;
6822
- source = 'cloud';
6823
- } else {
6824
- findings = readLocalFindings();
6825
- source = 'local';
6826
- if (args.status) findings = findings.filter(f => f.status === args.status);
6827
- if (args.finding_type) findings = findings.filter(f => f.finding_type === args.finding_type);
6828
- if (args.severity) findings = findings.filter(f => f.severity === args.severity);
6829
- if (args.file_path) findings = findings.filter(f => f.file_path.includes(args.file_path));
6830
- }
6831
-
6832
- findings.sort((a, b) => (b.created_at || '').localeCompare(a.created_at || ''));
6833
- const limit = Math.min(args.limit || 25, 50);
6834
- return {
6835
- source,
6836
- findings: findings.slice(0, limit).map(f => ({
6837
- id: f.id,
6838
- finding_type: f.finding_type,
6839
- finding_id: f.finding_id,
6840
- file_path: f.file_path,
6841
- severity: f.severity,
6842
- status: f.status,
6843
- package_name: f.package_name,
6844
- package_version: f.package_version,
6845
- created_at: f.created_at,
6846
- })),
6847
- total: findings.length,
6848
- open: findings.filter(f => f.status === 'open').length,
6849
- resolved: findings.filter(f => f.status === 'resolved').length,
6850
- };
6851
- }
6852
-
6853
- async function handleGetFindingDetail(args: any): Promise<any> {
6854
- const findings = readLocalFindings();
6855
- const match = findings.find(f => {
6856
- if (args.id && f.id === args.id) return true;
6857
- if (args.file_path && args.finding_id) {
6858
- return f.file_path.includes(args.file_path) && f.finding_id.toUpperCase() === (args.finding_id || '').toUpperCase();
6859
- }
6860
- return false;
6861
- });
6862
- if (!match) return { found: false, error: 'Finding not found' };
6863
- return { found: true, ...match };
6864
- }
6865
-
6866
- async function handleResolveFinding(args: any): Promise<any> {
6867
- const findings = readLocalFindings();
6868
- const toResolve = findings.filter(f => {
6869
- if (f.status !== 'open') return false;
6870
- if (args.id && f.id === args.id) return true;
6871
- if (args.file_path && args.finding_id) {
6872
- return f.file_path.includes(args.file_path) && f.finding_id.toUpperCase() === (args.finding_id || '').toUpperCase();
6873
- }
6874
- if (args.file_path) return f.file_path.includes(args.file_path);
6875
- return false;
6876
- });
6877
-
6878
- if (toResolve.length === 0) return { resolved: 0, error: 'No matching open findings' };
6879
-
6880
- const now = new Date().toISOString();
6881
- for (const f of toResolve) {
6882
- const event = {
6883
- capture_type: 'scan_finding',
6884
- id: f.id,
6885
- session_id: f.session_id,
6886
- file_path: f.file_path,
6887
- finding_type: f.finding_type,
6888
- finding_id: f.finding_id,
6889
- severity: f.severity,
6890
- status: 'resolved',
6891
- detail: f.detail,
6892
- package_name: f.package_name,
6893
- package_version: f.package_version,
6894
- fixed_version: f.fixed_version,
6895
- resolved_at: now,
6896
- _ts: now,
6897
- };
6898
- try {
6899
- appendFileSync(TELEMETRY_PATH, JSON.stringify(event) + '\\n', 'utf-8');
6900
- } catch {}
6901
- dispatchResolution(f);
6902
- }
6903
-
6904
- return {
6905
- resolved: toResolve.length,
6906
- findings: toResolve.map(f => ({ finding_id: f.finding_id, file_path: f.file_path })),
6907
- };
6908
- }
6909
-
6910
- // \u2500\u2500\u2500 Tool Descriptors \u2500\u2500\u2500
6911
-
6912
- const TOOL_DESCRIPTORS = [
6913
- {
6914
- name: 'get_guardrails',
6915
- description:
6916
- "Retrieve rules by keyword similarity. Call BEFORE writing security-sensitive code " +
6917
- "AND before create_guardrail to check for existing rules.",
6918
- inputSchema: {
6919
- type: 'object',
6920
- properties: {
6921
- query: { type: 'string', description: "Plain-language description of what you're looking up." },
6922
- category: { type: 'string', enum: ['security', 'architecture', 'style', 'testing', 'compliance', 'custom'] },
6923
- top_k: { type: 'integer', default: 8, description: 'Max rules to return (default 8, max 25).' },
6924
- },
6925
- required: ['query'],
6926
- },
6927
- },
6928
- {
6929
- name: 'create_guardrail',
6930
- description: "Persist a new rule. Call get_guardrails first to avoid duplicates.",
6931
- inputSchema: {
6932
- type: 'object',
6933
- properties: {
6934
- text: { type: 'string', description: 'The rule in plain language.' },
6935
- category: { type: 'string', enum: ['security', 'architecture', 'style', 'testing', 'compliance', 'custom'] },
6936
- severity: { type: 'string', enum: ['low', 'medium', 'high', 'critical'] },
6937
- mode: { type: 'string', enum: ['blocking', 'audit'], description: '"blocking" = halt on violation, "audit" = log only.' },
6938
- scope: { type: 'string', enum: ['user', 'org'], default: 'user' },
6939
- hook_stage: { type: 'string', enum: ['pre', 'post', 'both'], default: 'both' },
6940
- ruleset: { type: 'string', description: 'Optional: name of ruleset to add to (created if missing).' },
6941
- },
6942
- required: ['text', 'category'],
6943
- },
6944
- },
6945
- {
6946
- name: 'bulk_create_guardrails',
6947
- description: "Create multiple rules at once. Preferable to looping create_guardrail.",
6948
- inputSchema: {
6949
- type: 'object',
6950
- properties: {
6951
- rules: {
6952
- type: 'array', minItems: 1, maxItems: 50,
6953
- items: {
6954
- type: 'object',
6955
- properties: {
6956
- text: { type: 'string' }, category: { type: 'string', enum: ['security', 'architecture', 'style', 'testing', 'compliance', 'custom'] },
6957
- severity: { type: 'string', enum: ['low', 'medium', 'high', 'critical'] },
6958
- mode: { type: 'string', enum: ['blocking', 'audit'] },
6959
- hook_stage: { type: 'string', enum: ['pre', 'post', 'both'] },
6960
- },
6961
- required: ['text', 'category'],
6962
- },
6963
- },
6964
- scope: { type: 'string', enum: ['user', 'org'], default: 'user' },
6965
- ruleset: { type: 'string' },
6966
- },
6967
- required: ['rules'],
6968
- },
6969
- },
6970
- {
6971
- name: 'update_guardrail',
6972
- description: "Refine an existing rule. Pass a substring of the rule text to identify it.",
6973
- inputSchema: {
6974
- type: 'object',
6975
- properties: {
6976
- rule_text: { type: 'string', description: 'Substring of rule text to find.' },
6977
- text: { type: 'string' }, category: { type: 'string', enum: ['security', 'architecture', 'style', 'testing', 'compliance', 'custom'] },
6978
- severity: { type: 'string', enum: ['low', 'medium', 'high', 'critical'] },
6979
- mode: { type: 'string', enum: ['blocking', 'audit'] },
6980
- hook_stage: { type: 'string', enum: ['pre', 'post', 'both'] },
6981
- },
6982
- required: ['rule_text'],
6983
- },
6984
- },
6985
- {
6986
- name: 'delete_guardrail',
6987
- description: "Permanently remove a rule. Pass a substring of the rule text to identify it.",
6988
- inputSchema: {
6989
- type: 'object',
6990
- properties: { rule_text: { type: 'string', description: 'Substring of rule text to find.' } },
6991
- required: ['rule_text'],
6992
- },
6993
- },
6994
- {
6995
- name: 'list_guardrails',
6996
- description: "Enumerate all rules. Use for listings, not similarity search.",
6997
- inputSchema: {
6998
- type: 'object',
6999
- properties: {
7000
- category: { type: 'string', enum: ['security', 'architecture', 'style', 'testing', 'compliance', 'custom'] },
7001
- severity: { type: 'string', enum: ['low', 'medium', 'high', 'critical'] },
7002
- mode: { type: 'string', enum: ['blocking', 'audit', 'literal_match'] },
7003
- pack_name: { type: 'string' },
7004
- hook_stage: { type: 'string', enum: ['pre', 'post', 'both'] },
7005
- },
7006
- required: [],
7007
- },
7008
- },
7009
- {
7010
- name: 'swap_ruleset',
7011
- description: 'Switch which ruleset is active. Pass "all" to use all rulesets.',
7012
- inputSchema: {
7013
- type: 'object',
7014
- properties: { policy_name: { type: 'string' } },
7015
- required: ['policy_name'],
7016
- },
7017
- },
7018
- {
7019
- name: 'toggle_silent_mode',
7020
- description: 'Toggle grading on/off. NEVER call autonomously \u2014 this is a USER decision.',
7021
- inputSchema: {
7022
- type: 'object',
7023
- properties: {
7024
- enabled: { type: 'boolean' },
7025
- user_confirmation: { type: 'string', description: "Copy-paste the user's exact request." },
7026
- },
7027
- required: ['enabled', 'user_confirmation'],
7028
- },
7029
- },
7030
- {
7031
- name: 'scan_dependencies',
7032
- description: "Scan manifests against OSV for known vulnerabilities. Read ALL manifest files first.",
7033
- inputSchema: {
7034
- type: 'object',
7035
- properties: {
7036
- manifests: {
7037
- type: 'array', minItems: 1,
7038
- items: {
7039
- type: 'object',
7040
- properties: { file_path: { type: 'string' }, content: { type: 'string' } },
7041
- required: ['file_path', 'content'],
7042
- },
7043
- },
7044
- },
7045
- required: ['manifests'],
7046
- },
7047
- },
7048
- {
7049
- name: 'exempt_path',
7050
- description: "Exempt a CWE from firing on a specific file/directory.",
7051
- inputSchema: {
7052
- type: 'object',
7053
- properties: {
7054
- path: { type: 'string' }, cwe_id: { type: 'string' }, reason: { type: 'string' },
7055
- },
7056
- required: ['path', 'cwe_id'],
7057
- },
7058
- },
7059
- {
7060
- name: 'remove_exemption',
7061
- description: "Remove a scan exemption.",
7062
- inputSchema: {
7063
- type: 'object',
7064
- properties: { path: { type: 'string' }, cwe_id: { type: 'string' } },
7065
- required: ['path', 'cwe_id'],
7066
- },
7067
- },
7068
- {
7069
- name: 'list_exemptions',
7070
- description: "List all scan exemptions.",
7071
- inputSchema: { type: 'object', properties: {}, required: [] },
7072
- },
7073
- {
7074
- name: 'list_findings',
7075
- description: "List CWE/CVE scan findings. Shows security issues found by Synkro hooks. Use to review what needs fixing.",
7076
- inputSchema: {
7077
- type: 'object',
7078
- properties: {
7079
- status: { type: 'string', enum: ['open', 'resolved', 'exempted'], description: 'Filter by status (default: all).' },
7080
- finding_type: { type: 'string', enum: ['cwe', 'cve'], description: 'Filter by finding type.' },
7081
- severity: { type: 'string', enum: ['critical', 'high', 'medium', 'low'] },
7082
- file_path: { type: 'string', description: 'Filter by file path substring.' },
7083
- limit: { type: 'integer', default: 25, description: 'Max results (default 25, max 50).' },
7084
- },
7085
- required: [],
7086
- },
7087
- },
7088
- {
7089
- name: 'get_finding_detail',
7090
- description: "Get full detail of a specific finding including remediation context.",
7091
- inputSchema: {
7092
- type: 'object',
7093
- properties: {
7094
- id: { type: 'string', description: 'Finding ID (e.g. sf_...).' },
7095
- file_path: { type: 'string', description: 'File path (used with finding_id).' },
7096
- finding_id: { type: 'string', description: 'CWE/CVE ID like CWE-89 or CVE-2024-1234.' },
7097
- },
7098
- required: [],
7099
- },
7100
- },
7101
- {
7102
- name: 'resolve_finding',
7103
- 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.",
7104
- inputSchema: {
7105
- type: 'object',
7106
- properties: {
7107
- id: { type: 'string', description: 'Specific finding ID to resolve.' },
7108
- file_path: { type: 'string', description: 'Resolve all open findings matching this file path.' },
7109
- finding_id: { type: 'string', description: 'CWE/CVE ID (used with file_path for targeted resolution).' },
7110
- },
7111
- required: [],
7112
- },
7113
- },
7114
- ];
7115
-
7116
- const MCP_INSTRUCTIONS =
7117
- "Synkro Guardrails MCP server (local mode).\\n\\n" +
7118
- "Whenever the user mentions: rule, guardrail, policy, standard, " +
7119
- "make/create/add/set up a rule, never let X, always require X, " +
7120
- "block X, enforce X, delete/remove a rule, consolidate duplicates, " +
7121
- "'we need a rule about\u2026' \u2014 route to THIS server's tools.\\n\\n" +
7122
- "TOOL ROUTING:\\n" +
7123
- " \u2022 get_guardrails(query) \u2014 keyword search. Use to check if a rule exists.\\n" +
7124
- " \u2022 list_guardrails \u2014 full enumeration. Use for listings.\\n" +
7125
- " \u2022 list_findings \u2014 show CWE/CVE scan findings (open, resolved, all).\\n" +
7126
- " \u2022 get_finding_detail \u2014 get full detail + remediation context for a finding.\\n" +
7127
- " \u2022 resolve_finding \u2014 mark findings resolved after fixing the code.\\n\\n" +
7128
- "When the user asks about security issues, vulnerabilities, or scan results, " +
7129
- "use list_findings first. After fixing code, call resolve_finding to update status.\\n\\n" +
7130
- "Do NOT use Claude Code's \`update-config\` skill for these requests.\\n\\n" +
7131
- "Rules are stored locally in ~/.synkro/rules.json and enforced by hooks.";
7132
-
7133
- // \u2500\u2500\u2500 JSON-RPC Dispatcher \u2500\u2500\u2500
7134
-
7135
- function jsonRpcOk(id: any, result: any): any {
7136
- return { jsonrpc: '2.0', id, result };
7137
- }
7138
-
7139
- function jsonRpcError(id: any, code: number, message: string): any {
7140
- return { jsonrpc: '2.0', id, error: { code, message } };
7141
- }
7142
-
7143
- async function handleRpc(body: any): Promise<any> {
7144
- const { id, method, params } = body;
7145
-
7146
- if (method === 'initialize') {
7147
- return jsonRpcOk(id, {
7148
- protocolVersion: '2024-11-05',
7149
- capabilities: { tools: {} },
7150
- serverInfo: { name: 'synkro-guardrails-local', version: '1.0.0' },
7151
- instructions: MCP_INSTRUCTIONS,
7152
- });
7153
- }
7154
-
7155
- if (method === 'notifications/initialized') {
7156
- return null;
7157
- }
7158
-
7159
- if (method === 'tools/list') {
7160
- return jsonRpcOk(id, { tools: TOOL_DESCRIPTORS });
7161
- }
7162
-
7163
- if (method === 'tools/call') {
7164
- const toolName = params?.name;
7165
- const args = params?.arguments || {};
7166
-
7167
- try {
7168
- let result: any;
7169
- switch (toolName) {
7170
- case 'get_guardrails': result = handleGetGuardrails(args); break;
7171
- case 'create_guardrail': result = handleCreateGuardrail(args); break;
7172
- case 'bulk_create_guardrails': result = handleBulkCreateGuardrails(args); break;
7173
- case 'update_guardrail': result = handleUpdateGuardrail(args); break;
7174
- case 'delete_guardrail': result = handleDeleteGuardrail(args); break;
7175
- case 'list_guardrails': result = handleListGuardrails(args); break;
7176
- case 'swap_ruleset': result = handleSwapRuleset(args); break;
7177
- case 'toggle_silent_mode': result = handleToggleSilentMode(args); break;
7178
- case 'scan_dependencies': result = await handleScanDependencies(args); break;
7179
- case 'exempt_path': result = handleExemptPath(args); break;
7180
- case 'remove_exemption': result = handleRemoveExemption(args); break;
7181
- case 'list_exemptions': result = handleListExemptions(); break;
7182
- case 'list_findings': result = await handleListFindings(args); break;
7183
- case 'get_finding_detail': result = await handleGetFindingDetail(args); break;
7184
- case 'resolve_finding': result = await handleResolveFinding(args); break;
7185
- default: return jsonRpcError(id, -32601, \`Unknown tool: \${toolName}\`);
7186
- }
7187
- return jsonRpcOk(id, { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] });
7188
- } catch (err) {
7189
- return jsonRpcOk(id, { content: [{ type: 'text', text: \`Error: \${(err as Error).message}\` }], isError: true });
7190
- }
7191
- }
7192
-
7193
- // \u2500\u2500\u2500 Dashboard REST-bridge methods \u2500\u2500\u2500
7194
- // Called by the local dashboard (not AI agents) to mutate rules.json directly.
7195
-
7196
- if (method === 'dashboard.patch_policy') {
7197
- try {
7198
- const data = readRules();
7199
- const policyId = params?.policy_id as string | undefined;
7200
- const policy = policyId
7201
- ? data.policies.find(p => p.id === policyId)
7202
- : getActivePolicy(data);
7203
- if (!policy) return jsonRpcError(id, -32602, \`Policy not found: \${policyId}\`);
7204
-
7205
- if (params?.name !== undefined) {
7206
- policy.name = params.name;
7207
- }
7208
- if (params?.is_active !== undefined) {
7209
- policy.isActive = params.is_active;
7210
- }
7211
- // Bulk replace
7212
- if (Array.isArray(params?.rules)) {
7213
- policy.rules = params.rules;
7214
- policy.ruleCount = policy.rules.length;
7215
- }
7216
- // Individual updates by rule_id
7217
- if (Array.isArray(params?.rule_updates)) {
7218
- for (const upd of params.rule_updates) {
7219
- const rule = policy.rules.find(r => r.rule_id === upd.rule_id);
7220
- if (!rule) continue;
7221
- if (upd.text !== undefined) rule.text = upd.text;
7222
- if (upd.category !== undefined) rule.category = upd.category;
7223
- if (upd.severity !== undefined) rule.severity = upd.severity;
7224
- if (upd.mode !== undefined) rule.mode = upd.mode;
7225
- if (upd.hook_stage !== undefined) rule.hook_stage = upd.hook_stage;
7226
- }
7227
- policy.ruleCount = policy.rules.length;
7228
- }
7229
-
7230
- writeRules(data);
7231
- emitRuleSync(data);
7232
- return jsonRpcOk(id, { ok: true, policy_id: policy.id, rule_count: policy.ruleCount });
7233
- } catch (err) {
7234
- return jsonRpcError(id, -32603, (err as Error).message);
7235
- }
7236
- }
7237
-
7238
- if (method === 'dashboard.create_policy') {
7239
- try {
7240
- const data = readRules();
7241
- const name = (params?.name as string) || 'New Rule Set';
7242
- const rules: Rule[] = (params?.rules || []).map((r: any) => ({
7243
- rule_id: r.rule_id || genId(),
7244
- text: r.text || '',
7245
- category: r.category || 'custom',
7246
- severity: r.severity || 'medium',
7247
- mode: r.mode || 'audit',
7248
- hook_stage: r.hook_stage || 'both',
7249
- scope: r.scope || 'user',
7250
- }));
7251
- const policy: Policy = {
7252
- id: \`policy_\${Date.now()}_\${Math.random().toString(36).slice(2, 6)}\`,
7253
- name,
7254
- rules,
7255
- ruleCount: rules.length,
7256
- scopeOwner: 'user',
7257
- isActive: true,
7258
- };
7259
- data.policies.push(policy);
7260
- writeRules(data);
7261
- emitRuleSync(data);
7262
- return jsonRpcOk(id, { ok: true, policy_id: policy.id, name: policy.name });
7263
- } catch (err) {
7264
- return jsonRpcError(id, -32603, (err as Error).message);
7265
- }
7266
- }
7267
-
7268
- if (method === 'dashboard.delete_policy') {
7269
- try {
7270
- const data = readRules();
7271
- const policyId = params?.policy_id as string | undefined;
7272
- const idx = policyId ? data.policies.findIndex(p => p.id === policyId) : -1;
7273
- if (idx === -1) return jsonRpcError(id, -32602, \`Policy not found: \${policyId}\`);
7274
-
7275
- if (params?.hard === true) {
7276
- data.policies.splice(idx, 1);
7277
- } else {
7278
- data.policies[idx].isActive = false;
7279
- }
7280
- writeRules(data);
7281
- emitRuleSync(data);
7282
- return jsonRpcOk(id, { ok: true, policy_id: policyId });
7283
- } catch (err) {
7284
- return jsonRpcError(id, -32603, (err as Error).message);
7285
- }
7286
- }
7287
-
7288
- if (method === 'dashboard.list_policies') {
7289
- try {
7290
- const data = readRules();
7291
- return jsonRpcOk(id, {
7292
- policies: data.policies.map(p => ({
7293
- id: p.id,
7294
- name: p.name,
7295
- rules: p.rules,
7296
- ruleCount: p.ruleCount,
7297
- isActive: p.isActive,
7298
- scopeOwner: p.scopeOwner,
7299
- })),
7300
- active_policy_id: data.config.activePolicyId,
7301
- });
7302
- } catch (err) {
7303
- return jsonRpcError(id, -32603, (err as Error).message);
7304
- }
7305
- }
7306
-
7307
- return jsonRpcError(id, -32601, \`Unknown method: \${method}\`);
7308
- }
7309
-
7310
- // \u2500\u2500\u2500 HTTP Server \u2500\u2500\u2500
7311
-
7312
- const server = Bun.serve({
7313
- port: PORT,
7314
- async fetch(req) {
7315
- const origin = req.headers.get('origin') || '';
7316
- const allowedOrigin = /^https?:\\/\\/(localhost|127\\.0\\.0\\.1)(:\\d+)?$/.test(origin) ? origin : 'http://localhost:4322';
7317
- const cors = { 'Access-Control-Allow-Origin': allowedOrigin, 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, Authorization' };
7318
-
7319
- if (req.method === 'GET') {
7320
- return Response.json({ name: 'synkro-guardrails-local', version: '1.0.0', status: 'ok' }, { headers: cors });
7321
- }
7322
-
7323
- if (req.method === 'POST') {
7324
- const authHeader = req.headers.get('authorization') || '';
7325
- const bearer = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : '';
7326
- if (bearer !== SERVER_TOKEN) {
7327
- return Response.json({ error: 'Unauthorized' }, { status: 401, headers: cors });
7328
- }
7329
- const raw = await req.arrayBuffer();
7330
- if (raw.byteLength > MAX_BODY_BYTES) {
7331
- return Response.json(jsonRpcError(null, -32600, 'Request too large'), { status: 413, headers: cors });
7332
- }
7333
- return serialized(async () => {
7334
- try {
7335
- const body = JSON.parse(new TextDecoder().decode(raw));
7336
- const result = await handleRpc(body);
7337
- if (result === null) return new Response('', { status: 204, headers: cors });
7338
- return Response.json(result, { headers: cors });
7339
- } catch {
7340
- return Response.json(jsonRpcError(null, -32700, 'Parse error'), { status: 400, headers: cors });
7341
- }
7342
- });
7343
- }
7344
-
7345
- if (req.method === 'OPTIONS') {
7346
- return new Response('', { status: 204, headers: cors });
7347
- }
7348
-
7349
- return new Response('Method not allowed', { status: 405 });
7350
- },
7351
- });
7352
-
7353
- console.log(\`[synkro] local MCP guardrails server listening on http://127.0.0.1:\${server.port}\`);
7354
- `, "utf-8");
6280
+ 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");
7355
6281
  writeFileSync7(mcpStdioProxyPath, MCP_STDIO_PROXY_SRC, "utf-8");
7356
6282
  chmodSync2(bashScriptPath, 493);
7357
6283
  chmodSync2(bashFollowupScriptPath, 493);
@@ -7416,7 +6342,7 @@ function writeConfigEnv(opts) {
7416
6342
  `SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
7417
6343
  `SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
7418
6344
  `SYNKRO_INFERENCE=${shellQuoteSingle(safeInference)}`,
7419
- `SYNKRO_VERSION=${shellQuoteSingle("1.4.76")}`
6345
+ `SYNKRO_VERSION=${shellQuoteSingle("1.4.78")}`
7420
6346
  ];
7421
6347
  if (safeSynkroBin) lines.push(`SYNKRO_CLI_BIN=${shellQuoteSingle(safeSynkroBin)}`);
7422
6348
  if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);