@synkro-sh/cli 1.4.84 → 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 +62 -6
- 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
|
|
@@ -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) {
|