prism-mcp-server 15.7.2 → 15.7.4

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/README.md CHANGED
@@ -124,47 +124,35 @@ Three entry points:
124
124
 
125
125
  See [KNOWLEDGE_INGESTION.md](docs/KNOWLEDGE_INGESTION.md) for full setup guide.
126
126
 
127
- ### Cost comparison
127
+ ### Routing accuracy
128
128
 
129
- Benchmark: 19 queries (routing + code knowledge + clinical), May 2026:
129
+ **Head-to-head: prism-coder:14b vs Claude Opus** (25-case benchmark, production system prompt, May 2026):
130
130
 
131
- | Architecture | Routing | Code Knowledge | Clinical | Annual Cost (1K/day) |
132
- |---|---|---|---|---|
133
- | **Prism cascade** (14b→RAG→Sonnet) | 100% (local) | RAG-powered | Sonnet | **~$330/yr** |
134
- | Claude Opus for everything | ~30% (no tools) | Training data | Opus | ~$10,600/yr |
135
-
136
- **84% cost savings.** Routing is free and 100% accurate. Cloud only for the 20% of queries that need deep reasoning.
137
-
138
- The routing cascade validates each response against the known tool names and escalates on empty, truncated, or hallucinated tool calls.
139
-
140
- **Routing accuracy** ([102-case Prism eval](tests/benchmarks/prism-routing-100/README.md), v36/v7 system prompt, 3-seed mean, May 2026):
141
-
142
- | Model | Accuracy | Cost/req | Latency | Runs on | AAC | Edge cases |
143
- |---|---|---|---|---|---|---|
144
- | Claude Sonnet 4 | **99%** | ~$0.01 | 3.2s | Cloud | 100% | 83% |
145
- | **prism-coder:32b** swe14 | **100.0%** | **$0** | 1.4s | Mac 24GB+ | **100%** | **100%** |
146
- | **prism-coder:8b** v36 | **100.0%** | **$0** | **0.8s** | iPhone/iPad 8GB | **100%** | **100%** |
147
- | **prism-coder:14b** v36 | **100.0%** | **$0** | **1.1s** | Mac 24GB+ / iPad Pro 16GB | **100%** | **100%** |
148
- | Claude Opus 4.7 | **98.3%** | ~$0.05 | 3.0s | Cloud | 100% | 83% |
149
- | **prism-coder:1.7b** v42 | **100.0%** | **$0** | 1.6s | Any device | **100%** | **100%** |
150
- | **14B→32B cascade** | **100.0%** | **~$0** | ~1.1s¹ | Mac 24GB+ | **100%** | **100%** |
151
-
152
- ¹ ~99% of requests served by 14B at 1.1s; 32B for the ~1% 14B misses.
131
+ | Metric | prism-coder:14b | Claude Opus 4 |
132
+ |---|---|---|
133
+ | **Overall accuracy** | **96% (24/25)** | 88% (22/25) |
134
+ | **Tool routing** (15 tests) | **93% (14/15)** | 80% (12/15) |
135
+ | **Abstention** (10 tests) | **100% (10/10)** | **100% (10/10)** |
136
+ | **Avg latency** | **0.8s** | 5.5s |
137
+ | **Cost per query** | **$0** | ~$0.017 |
138
+ | **Annual @ 1K/day** | **$0** | ~$6,100 |
153
139
 
154
- **Extended eval eval_300** (300 cases, 17 tools + NO_TOOL, 9 categories, 3-seed validated, May 2026):
140
+ prism-coder:14b beats Opus on tool routing 7x faster, free, runs offline.
155
141
 
156
- | Model | eval_300 strict | Categories |
157
- |---|---|---|
158
- | **prism-coder:32b** swe14 | **300/300 (100%)** | abstention 20/20, adversarial 70/70, cascade 25/25, disambiguation 40/40, edge_case 25/25, multi_intent 20/20, natural_phrasing 50/50, param_extraction 25/25, verifier 25/25 |
159
- | **prism-coder:14b** s17 | **299/300 (99.7%)** | 1 failure in adversarial_trap |
142
+ **eval_300** (300 cases, 17 tools + NO_TOOL, 9 categories, 3-seed validated):
160
143
 
161
- The eval_300 suite covers natural phrasing, adversarial traps (CS/meta questions that should NOT trigger tools), disambiguation between similar tools, edge cases (single-word prompts), multi-intent cascades, parameter extraction, and verifier-style prompts.
144
+ | Model | eval_300 strict | Size | Latency |
145
+ |---|---|---|---|
146
+ | **prism-coder:32b** | **300/300 (100%)** | 19 GB | ~1.4s |
147
+ | **prism-coder:14b** | **299/300 (99.7%)** | 9 GB | ~0.8s |
148
+ | **prism-coder:4b** | **300/300 (100%)** | 2.5 GB | ~0.5s |
149
+ | **prism-coder:1.7b** | **300/300 (100%)** | 2.2 GB | ~1.6s |
162
150
 
163
- **Why this matters for a life-critical AAC app**: a child in a hospital without WiFi, a nonverbal adult on an airplane, or a family on a budget gets Claude-grade routing accuracy with zero cloud dependency — and the AAC path (expressing pain, asking for help) routes correctly **100% of the time across all tiers and all seeds tested**.
151
+ Categories: abstention, adversarial traps, cascade, disambiguation, edge cases, multi-intent, natural phrasing, parameter extraction, verifier prompts.
164
152
 
165
- **What it does NOT mean**: these scores measure routing precision on a narrow 6-tool taxonomy, not general intelligence. Claude outperforms these models on everything outside this task. The value is **offline reliability at zero cost**, not replacing Claude.
153
+ **What this means**: a child in a hospital without WiFi, a nonverbal adult on an airplane, or a family on a budget gets Claude-grade routing accuracy with zero cloud dependency the AAC path routes correctly **100% of the time across all tiers**.
166
154
 
167
- > **The prompt engineering breakthrough**: Q4_K_M quantized models confuse semantically similar tool names when routing rules use plain keyword lists. Two structural fixes eliminated all confusion: (1) replacing `-> plain text` with `-> respond directly (no tool)`, and (2) adding category labels (`CONVERSATION RECALL:` / `SAVED KNOWLEDGE:`) as semantic anchors stronger than keyword matching. Combined effect: 14B went from 87% → 100% on the 102-case Prism eval (v36/v7 system prompt, 3-seed mean).
155
+ **What it does NOT mean**: these scores measure routing precision on a 17-tool taxonomy, not general intelligence. Claude outperforms on everything outside this task. The value is **offline reliability at zero cost**, not replacing Claude. Code and clinical knowledge come from RAG via `knowledge_search`.
168
156
 
169
157
  ### 🔍 L3 Grounding Verifier
170
158
  When `prism_infer` receives an `evidence` payload, the grounding verifier automatically checks the model's response against the provided evidence before returning to the caller. Unverified or hallucinated claims are flagged. This is the third layer (L3) of the cascade — after tool routing (L1) and confidence gating (L2).
@@ -423,6 +411,28 @@ As of v14.0.0, Prism's algorithm exports are a **stable public contract** under
423
411
  | [PrismAAC](https://github.com/dcostenco/prism-aac) | Spreading-activation phrase ranking (recency × frequency × per-user history). Caregiver corrections auto-harvest into the personalization corpus via the audit-hooks postflight harvester. The on-device 7B model + this algorithm stack is what makes PrismAAC defensible. |
424
412
  | Synalux portal | Tier-aware model routing using experience bias on prior outcomes per fingerprint. HIPAA-compliant clinical scribe with on-device-first privacy guarantees. |
425
413
 
414
+ ## CLI Reference
415
+
416
+ Prism Coder includes a CLI for session management, code review, and sync operations.
417
+
418
+ ```bash
419
+ prism load <project> # Load session context (same as session_load_context MCP tool)
420
+ prism save # Save session state (ledger + handoff)
421
+ prism ledger <project> # Save a session log entry (same as session_save_ledger)
422
+ prism handoff <project> # Update live project state for next session
423
+ prism push # Push local SQLite data to Supabase cloud
424
+ prism sync # Cross-backend data synchronization
425
+ prism search <query> # Search code across repos (exact, regex, symbol, semantic)
426
+ prism review <files...> # AI code review — security, performance, style
427
+ prism scan <files...> # Security scan — secrets, licenses, Dockerfile
428
+ prism dora # Show DORA metrics for current project
429
+ prism scm # Source control, AI review, security scanning
430
+ prism verify # Manage the verification harness
431
+ prism status # Check verification state and config drift
432
+ prism generate # Bless current rubric as canonical
433
+ prism register-models # Alias dcostenco/prism-coder:* → prism-coder:*
434
+ ```
435
+
426
436
  ## Testing
427
437
 
428
438
  ```bash
@@ -870,11 +870,10 @@ return false;}
870
870
  res.writeHead(400, { "Content-Type": "application/json" });
871
871
  return res.end(JSON.stringify({ error: "filename and content are required" }));
872
872
  }
873
- // Write uploaded content to a temp file
874
- const tmpDir = path.join(os.tmpdir(), "prism-import");
875
- fs.mkdirSync(tmpDir, { recursive: true });
876
- const safeFilename = path.basename(filename); // prevent path traversal
877
- const tmpFile = path.join(tmpDir, `upload-${Date.now()}-${safeFilename}`);
873
+ // Write uploaded content to a secure temp directory
874
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "prism-import-"));
875
+ const safeFilename = path.basename(filename).replace(/[^a-zA-Z0-9._-]/g, "_");
876
+ const tmpFile = path.join(tmpDir, safeFilename);
878
877
  fs.writeFileSync(tmpFile, content, "utf-8");
879
878
  try {
880
879
  const { universalImporter } = await import("../utils/universalImporter.js");
@@ -1842,8 +1842,8 @@ function loadPipelines() {
1842
1842
  var maxIter = (p.parsedSpec && p.parsedSpec.maxIterations) ? p.parsedSpec.maxIterations : '?';
1843
1843
  html += '<div style="padding:0.75rem 1rem;background:rgba(15,23,42,0.6);border-radius:8px;border-left:3px solid ' + statusColor + ';">';
1844
1844
  html += '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.35rem">';
1845
- html += '<span style="font-weight:600;color:var(--text-primary)">' + emoji + ' ' + p.status + '</span>';
1846
- html += '<span style="font-size:0.7rem;font-family:var(--font-mono);color:var(--text-muted)">' + p.id.slice(0, 8) + '…</span>';
1845
+ html += '<span style="font-weight:600;color:var(--text-primary)">' + emoji + ' ' + escapeHtml(p.status) + '</span>';
1846
+ html += '<span style="font-size:0.7rem;font-family:var(--font-mono);color:var(--text-muted)">' + escapeHtml(p.id.slice(0, 8)) + '…</span>';
1847
1847
  html += '</div>';
1848
1848
  html += '<div style="font-size:0.82rem;color:var(--text-secondary);margin-bottom:0.35rem">' + escapeHtml(objective) + '</div>';
1849
1849
  html += '<div style="display:flex;gap:1rem;font-size:0.72rem;color:var(--text-muted);flex-wrap:wrap">';
@@ -1856,7 +1856,7 @@ function loadPipelines() {
1856
1856
  html += '<div style="font-size:0.72rem;color:var(--accent-rose);margin-top:0.35rem;padding:0.3rem 0.5rem;background:rgba(244,63,94,0.08);border-radius:4px">⚠ ' + escapeHtml(p.error.slice(0, 200)) + '</div>';
1857
1857
  }
1858
1858
  if (isActive) {
1859
- html += '<div style="margin-top:0.5rem"><button onclick="abortPipeline(this.dataset.id)" data-id="' + p.id + '" class="cleanup-btn" style="font-size:0.72rem">🛑 Abort Pipeline</button></div>';
1859
+ html += '<div style="margin-top:0.5rem"><button onclick="abortPipeline(this.dataset.id)" data-id=''' + escapeHtml(p.id) + ''' class="cleanup-btn" style="font-size:0.72rem">🛑 Abort Pipeline</button></div>';
1860
1860
  }
1861
1861
  html += '</div>';
1862
1862
  }
@@ -4021,7 +4021,7 @@ function loadSchedulerStatus() {
4021
4021
  parts.push('</div>');
4022
4022
  errors = [t.ttlSweep.error, t.importanceDecay.error, t.compaction.error, t.deepPurge.error].filter(Boolean);
4023
4023
  if (errors.length > 0) {
4024
- parts.push('<div style="color:var(--accent-rose);margin-top:0.3rem;font-size:0.7rem">⚠️ ' + errors.join(' | ') + '</div>');
4024
+ parts.push('<div style="color:var(--accent-rose);margin-top:0.3rem;font-size:0.7rem">⚠️ ' + errors.map(escapeHtml).join(' | ') + '</div>');
4025
4025
  }
4026
4026
  parts.push('</div>');
4027
4027
  }
@@ -4199,7 +4199,7 @@ function loadGraphMetrics() {
4199
4199
  lastRoute = m.cognitive.last_route || '—';
4200
4200
  lastConcept = m.cognitive.last_concept || '(none)';
4201
4201
  lastConf = m.cognitive.last_confidence !== null ? Math.round(m.cognitive.last_confidence * 100) + '%' : '—';
4202
- parts.push('<br>Last: ' + lastRoute + ' → ' + lastConcept + ' (' + lastConf + ')');
4202
+ parts.push('<br>Last: ' + escapeHtml(lastRoute) + ' → ' + escapeHtml(lastConcept) + ' (' + lastConf + ')');
4203
4203
  parts.push('<br><span style="color:var(--text-muted)">' + timeAgo(m.cognitive.last_run_at) + '</span>');
4204
4204
  }
4205
4205
  parts.push('</div>');
@@ -18,12 +18,41 @@
18
18
  import { createHmac, timingSafeEqual } from "crypto";
19
19
  import { handleGitHubWebhook } from "../tools/ingestHandler.js";
20
20
  import { debugLog } from "../utils/logger.js";
21
+ import { ddInfo, ddWarn } from "../utils/ddLogger.js";
21
22
  const WEBHOOK_SECRET = process.env.GITHUB_WEBHOOK_SECRET || "";
22
23
  const GITHUB_TOKEN = process.env.GITHUB_TOKEN || "";
24
+ const PRISM_INGEST_API_KEY = process.env.PRISM_INGEST_API_KEY || "";
25
+ const IS_PRODUCTION = process.env.NODE_ENV === "production";
26
+ // ─── Rate Limiting (in-memory, per-IP) ─────────────────────────
27
+ const rateLimitMap = new Map();
28
+ const RATE_LIMIT_WINDOW_MS = 60_000;
29
+ const RATE_LIMIT_MAX = 10;
30
+ function checkRateLimit(ip) {
31
+ const now = Date.now();
32
+ const entry = rateLimitMap.get(ip);
33
+ if (!entry || now > entry.resetAt) {
34
+ rateLimitMap.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS });
35
+ return true;
36
+ }
37
+ entry.count++;
38
+ return entry.count <= RATE_LIMIT_MAX;
39
+ }
40
+ // Cleanup stale entries every 5 minutes
41
+ setInterval(() => {
42
+ const now = Date.now();
43
+ for (const [ip, entry] of rateLimitMap) {
44
+ if (now > entry.resetAt)
45
+ rateLimitMap.delete(ip);
46
+ }
47
+ }, 300_000);
23
48
  // ─── Signature Verification ────────────────────────────────────
24
49
  function verifySignature(payload, signature) {
25
50
  if (!WEBHOOK_SECRET) {
26
- debugLog("[webhook] GITHUB_WEBHOOK_SECRET not set — accepting all requests (dev mode)");
51
+ if (IS_PRODUCTION) {
52
+ debugLog("[webhook] GITHUB_WEBHOOK_SECRET not set in production — rejecting");
53
+ return false;
54
+ }
55
+ debugLog("[webhook] GITHUB_WEBHOOK_SECRET not set — accepting (dev mode only)");
27
56
  return true;
28
57
  }
29
58
  if (!signature)
@@ -38,8 +67,45 @@ function verifySignature(payload, signature) {
38
67
  return false;
39
68
  }
40
69
  }
70
+ // ─── Input Validation ──────────────────────────────────────────
71
+ const REPO_NAME_RE = /^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/;
72
+ const SAFE_PATH_RE = /^[a-zA-Z0-9._\-\/]+$/;
73
+ function validateRepoName(name) {
74
+ return REPO_NAME_RE.test(name) && !name.includes("..");
75
+ }
76
+ function validateFilePath(path) {
77
+ return SAFE_PATH_RE.test(path) && !path.includes("..") && !path.startsWith("/");
78
+ }
79
+ // ─── Ingest API Auth ───────────────────────────────────────────
80
+ function verifyIngestAuth(authHeader) {
81
+ if (!authHeader)
82
+ return false;
83
+ if (!PRISM_INGEST_API_KEY && !WEBHOOK_SECRET) {
84
+ if (IS_PRODUCTION)
85
+ return false;
86
+ return true;
87
+ }
88
+ const expectedKey = PRISM_INGEST_API_KEY || WEBHOOK_SECRET;
89
+ const token = authHeader.replace(/^Bearer\s+/i, "");
90
+ if (token.length !== expectedKey.length)
91
+ return false;
92
+ try {
93
+ return timingSafeEqual(Buffer.from(token), Buffer.from(expectedKey));
94
+ }
95
+ catch {
96
+ return false;
97
+ }
98
+ }
41
99
  // ─── Fetch File Content from GitHub API ─────────────────────────
42
100
  async function fetchFileFromGitHub(repoFullName, filePath, ref) {
101
+ if (!validateRepoName(repoFullName)) {
102
+ debugLog(`[webhook] Invalid repo name rejected: ${repoFullName}`);
103
+ return null;
104
+ }
105
+ if (!validateFilePath(filePath)) {
106
+ debugLog(`[webhook] Invalid file path rejected: ${filePath}`);
107
+ return null;
108
+ }
43
109
  const headers = {
44
110
  "Accept": "application/vnd.github.v3.raw",
45
111
  "User-Agent": "prism-mcp-webhook",
@@ -48,8 +114,8 @@ async function fetchFileFromGitHub(repoFullName, filePath, ref) {
48
114
  headers["Authorization"] = `Bearer ${GITHUB_TOKEN}`;
49
115
  }
50
116
  try {
51
- const url = `https://api.github.com/repos/${repoFullName}/contents/${filePath}?ref=${ref}`;
52
- const res = await fetch(url, { headers });
117
+ const url = `https://api.github.com/repos/${encodeURIComponent(repoFullName.split("/")[0])}/${encodeURIComponent(repoFullName.split("/")[1])}/contents/${filePath.split("/").map(encodeURIComponent).join("/")}?ref=${encodeURIComponent(ref)}`;
118
+ const res = await fetch(url, { headers, signal: AbortSignal.timeout(10_000) });
53
119
  if (!res.ok)
54
120
  return null;
55
121
  return await res.text();
@@ -79,6 +145,13 @@ function readBody(req, maxBytes = 10_000_000) {
79
145
  export async function handleWebhookRequest(req, res, pathname) {
80
146
  // ── GitHub Webhook ─────────────────────────────────────────
81
147
  if (pathname === "/api/github/webhook" && req.method === "POST") {
148
+ const ip = req.headers["x-forwarded-for"]?.split(",")[0]?.trim() || req.socket?.remoteAddress || "unknown";
149
+ if (!checkRateLimit(`wh:${ip}`)) {
150
+ ddWarn("webhook.rate_limited", { ip, endpoint: "github" });
151
+ res.writeHead(429, { "Content-Type": "application/json", "Retry-After": "60" });
152
+ res.end(JSON.stringify({ error: "Rate limit exceeded" }));
153
+ return true;
154
+ }
82
155
  try {
83
156
  const body = await readBody(req);
84
157
  const signature = req.headers["x-hub-signature-256"];
@@ -89,7 +162,13 @@ export async function handleWebhookRequest(req, res, pathname) {
89
162
  }
90
163
  const event = req.headers["x-github-event"] || "unknown";
91
164
  const payload = JSON.parse(body);
92
- debugLog(`[webhook] GitHub event: ${event}, repo: ${payload.repository?.full_name}`);
165
+ if (!payload.repository?.full_name || !validateRepoName(payload.repository.full_name)) {
166
+ res.writeHead(400, { "Content-Type": "application/json" });
167
+ res.end(JSON.stringify({ error: "Invalid repository name" }));
168
+ return true;
169
+ }
170
+ debugLog(`[webhook] GitHub event: ${event}, repo: ${payload.repository.full_name}`);
171
+ ddInfo("webhook.github.received", { event, repo: payload.repository.full_name });
93
172
  const result = await handleGitHubWebhook(event, payload, fetchFileFromGitHub);
94
173
  res.writeHead(200, { "Content-Type": "application/json" });
95
174
  res.end(JSON.stringify(result));
@@ -98,22 +177,27 @@ export async function handleWebhookRequest(req, res, pathname) {
98
177
  const msg = err instanceof Error ? err.message : String(err);
99
178
  debugLog(`[webhook] Error: ${msg}`);
100
179
  res.writeHead(500, { "Content-Type": "application/json" });
101
- res.end(JSON.stringify({ ok: false, message: msg }));
180
+ res.end(JSON.stringify({ ok: false, message: "Internal error" }));
102
181
  }
103
182
  return true;
104
183
  }
105
184
  // ── Generic Ingest API (open interface) ────────────────────
106
185
  if (pathname === "/api/v1/prism/ingest" && req.method === "POST") {
186
+ const ip = req.headers["x-forwarded-for"]?.split(",")[0]?.trim() || req.socket?.remoteAddress || "unknown";
187
+ if (!checkRateLimit(`ingest:${ip}`)) {
188
+ res.writeHead(429, { "Content-Type": "application/json", "Retry-After": "60" });
189
+ res.end(JSON.stringify({ error: "Rate limit exceeded" }));
190
+ return true;
191
+ }
107
192
  try {
108
- const body = await readBody(req);
109
- const payload = JSON.parse(body);
110
- // Minimal auth: require API key or JWT in Authorization header
111
193
  const auth = req.headers["authorization"] || "";
112
- if (!auth && WEBHOOK_SECRET) {
194
+ if (!verifyIngestAuth(auth)) {
113
195
  res.writeHead(401, { "Content-Type": "application/json" });
114
- res.end(JSON.stringify({ error: "Authorization required" }));
196
+ res.end(JSON.stringify({ error: "Invalid or missing API key" }));
115
197
  return true;
116
198
  }
199
+ const body = await readBody(req);
200
+ const payload = JSON.parse(body);
117
201
  const { ingestKnowledge } = await import("../tools/ingestHandler.js");
118
202
  const result = await ingestKnowledge({
119
203
  project: payload.project || "default",
@@ -128,7 +212,7 @@ export async function handleWebhookRequest(req, res, pathname) {
128
212
  catch (err) {
129
213
  const msg = err instanceof Error ? err.message : String(err);
130
214
  res.writeHead(500, { "Content-Type": "application/json" });
131
- res.end(JSON.stringify({ ok: false, message: msg }));
215
+ res.end(JSON.stringify({ ok: false, message: "Internal error" }));
132
216
  }
133
217
  return true;
134
218
  }
@@ -139,14 +223,7 @@ export async function handleWebhookRequest(req, res, pathname) {
139
223
  status: "ready",
140
224
  secret_configured: !!WEBHOOK_SECRET,
141
225
  github_token_configured: !!GITHUB_TOKEN,
142
- setup_instructions: {
143
- step1: "Set GITHUB_WEBHOOK_SECRET environment variable",
144
- step2: "In GitHub: Settings → Webhooks → Add webhook",
145
- step3: "Payload URL: https://your-domain/api/github/webhook",
146
- step4: "Content type: application/json",
147
- step5: "Secret: (same as GITHUB_WEBHOOK_SECRET)",
148
- step6: "Events: Just the push event",
149
- },
226
+ ingest_key_configured: !!PRISM_INGEST_API_KEY,
150
227
  }));
151
228
  return true;
152
229
  }
@@ -117,7 +117,7 @@ export async function setSetting(key, value) {
117
117
  args: [key, value],
118
118
  });
119
119
  // Keep the cache in sync so getSettingSync() reflects the new value immediately.
120
- if (settingsCache) {
120
+ if (settingsCache && typeof key === "string" && !["__proto__", "constructor", "prototype"].includes(key)) {
121
121
  settingsCache[key] = value;
122
122
  }
123
123
  return; // Success — exit
@@ -48,6 +48,8 @@ export function applySentinelBlock(existingContent, rulesBlock) {
48
48
  export function redactSettings(settings) {
49
49
  const redacted = {};
50
50
  for (const [k, v] of Object.entries(settings || {})) {
51
+ if (typeof k !== "string" || k === "__proto__" || k === "constructor" || k === "prototype")
52
+ continue;
51
53
  redacted[k] = REDACT_PATTERNS.some(p => p.test(k)) ? "**REDACTED**" : v;
52
54
  }
53
55
  return redacted;
@@ -98,8 +98,20 @@ async function generateQAPairs(chunk, source) {
98
98
  export async function ingestKnowledge(args) {
99
99
  const { project, source_label, chunk_size = 4000, } = args;
100
100
  let content = args.content || "";
101
- if (args.file_path && existsSync(args.file_path)) {
102
- content = readFileSync(args.file_path, "utf-8");
101
+ if (args.file_path) {
102
+ const resolved = require("path").resolve(args.file_path);
103
+ const blocked = ["/etc", "/var", "/usr", "/sys", "/proc", "/dev", "/root",
104
+ "/.ssh", "/.env", "/.git/config", "/private/etc"].some(p => resolved.startsWith(p) || resolved.includes("/."));
105
+ if (blocked) {
106
+ return {
107
+ project, source: source_label || "unknown", chunks_processed: 0,
108
+ entries_created: 0, status: "failed",
109
+ errors: ["Path not allowed: system/hidden files are blocked"],
110
+ };
111
+ }
112
+ if (existsSync(resolved)) {
113
+ content = readFileSync(resolved, "utf-8");
114
+ }
103
115
  }
104
116
  if (!content || content.trim().length < 100) {
105
117
  return {
@@ -1433,7 +1433,8 @@ export async function sessionExportMemoryHandler(args) {
1433
1433
  };
1434
1434
  // Serialize
1435
1435
  const ext = format === "markdown" ? "md" : format === "vault" ? "zip" : "json";
1436
- const filename = `prism-export-${project}-${dateSuffix}.${ext}`;
1436
+ const safeProject = project.replace(/[^a-zA-Z0-9_-]/g, "_");
1437
+ const filename = `prism-export-${safeProject}-${dateSuffix}.${ext}`;
1437
1438
  const outputPath = join(output_dir, filename);
1438
1439
  let content;
1439
1440
  if (format === "vault") {
@@ -29,7 +29,7 @@ let cached = null;
29
29
  let inflight = null;
30
30
  async function fetchOnce() {
31
31
  try {
32
- const res = await fetch(`${SYNALUX_BASE}/api/v1/skills/routing`, {
32
+ const res = await fetch(`${SYNALUX_BASE}/.well-known/prism/skills-routing.json`, {
33
33
  headers: { Accept: 'application/json' },
34
34
  // Routing is on every session_load_context, must not block long.
35
35
  signal: AbortSignal.timeout(2_500),
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Datadog Server-Side Logger
3
+ *
4
+ * Sends structured logs to Datadog HTTP Logs API.
5
+ * No agent needed — direct HTTPS POST to intake.
6
+ *
7
+ * Env: DD_API_KEY, DD_SITE (default datadoghq.com)
8
+ */
9
+ const DD_API_KEY = process.env.DD_API_KEY || "";
10
+ const DD_SITE = process.env.DD_SITE || "datadoghq.com";
11
+ const SERVICE = "prism-mcp";
12
+ const INTAKE_URL = `https://http-intake.logs.${DD_SITE}/api/v2/logs`;
13
+ const queue = [];
14
+ let flushTimer = null;
15
+ const FLUSH_INTERVAL_MS = 5_000;
16
+ const MAX_BATCH = 50;
17
+ function scheduleFlush() {
18
+ if (flushTimer)
19
+ return;
20
+ flushTimer = setTimeout(flush, FLUSH_INTERVAL_MS);
21
+ }
22
+ async function flush() {
23
+ flushTimer = null;
24
+ if (queue.length === 0 || !DD_API_KEY)
25
+ return;
26
+ const batch = queue.splice(0, MAX_BATCH);
27
+ try {
28
+ await fetch(INTAKE_URL, {
29
+ method: "POST",
30
+ headers: {
31
+ "Content-Type": "application/json",
32
+ "DD-API-KEY": DD_API_KEY,
33
+ },
34
+ body: JSON.stringify(batch),
35
+ signal: AbortSignal.timeout(5_000),
36
+ });
37
+ }
38
+ catch {
39
+ // Silent — don't crash the app if DD is unreachable
40
+ }
41
+ if (queue.length > 0)
42
+ scheduleFlush();
43
+ }
44
+ export function ddLog(level, message, context) {
45
+ if (!DD_API_KEY)
46
+ return;
47
+ queue.push({
48
+ ddsource: "nodejs",
49
+ ddtags: `env:${process.env.NODE_ENV || "development"},service:${SERVICE}`,
50
+ hostname: process.env.HOSTNAME || "prism-mcp",
51
+ service: SERVICE,
52
+ status: level,
53
+ message,
54
+ ...context,
55
+ timestamp: new Date().toISOString(),
56
+ });
57
+ scheduleFlush();
58
+ }
59
+ export function ddError(message, error, context) {
60
+ ddLog("error", message, {
61
+ ...context,
62
+ error: error ? {
63
+ message: error.message,
64
+ stack: error.stack?.split("\n").slice(0, 5).join("\n"),
65
+ name: error.name,
66
+ } : undefined,
67
+ });
68
+ }
69
+ export function ddInfo(message, context) {
70
+ ddLog("info", message, context);
71
+ }
72
+ export function ddWarn(message, context) {
73
+ ddLog("warn", message, context);
74
+ }
@@ -1,12 +1,21 @@
1
1
  import { PRISM_DEBUG_LOGGING } from "../config.js";
2
+ /**
3
+ * Sanitize a string for safe logging — strips control characters,
4
+ * newlines, and ANSI escape sequences that could be used for log
5
+ * injection or terminal escape attacks.
6
+ */
7
+ function sanitizeForLog(msg) {
8
+ return msg
9
+ .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "") // control chars (keep \n \r \t)
10
+ .replace(/\r?\n/g, " ⏎ ") // newlines → visible marker
11
+ .replace(/\x1b\[[0-9;]*[a-zA-Z]/g, ""); // ANSI escape sequences
12
+ }
2
13
  /**
3
14
  * Logs a message to stderr only if PRISM_DEBUG_LOGGING is true.
4
- * Use this for verbose traces (e.g., initialization, request tracking)
5
- * that should be hidden from users by default but remain available
6
- * for troubleshooting.
15
+ * Input is sanitized to prevent log injection attacks.
7
16
  */
8
17
  export function debugLog(message) {
9
18
  if (PRISM_DEBUG_LOGGING) {
10
- console.error(message);
19
+ console.error(sanitizeForLog(message));
11
20
  }
12
21
  }
@@ -47,6 +47,58 @@ function meetsMinSeverity(severity) {
47
47
  SEVERITY_ORDER.indexOf(currentConfig.minSeverity));
48
48
  }
49
49
  // ─── SSRF Protection ─────────────────────────────────────────
50
+ function isPrivateIP(ip) {
51
+ // Normalize: strip brackets for IPv6
52
+ const clean = ip.replace(/^\[|\]$/g, "").toLowerCase();
53
+ // IPv6 loopback and unspecified
54
+ if (clean === "::1" || clean === "::" || clean === "0:0:0:0:0:0:0:1" || clean === "0:0:0:0:0:0:0:0")
55
+ return true;
56
+ // IPv4-mapped IPv6 — two forms:
57
+ // Dotted: ::ffff:127.0.0.1 → extract IPv4 directly
58
+ // Hex: ::ffff:7f00:1 (Node normalizes dotted to this) → decode hex groups
59
+ const v4mapped = clean.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
60
+ const v4hex = clean.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
61
+ let ipv4 = clean;
62
+ if (v4mapped) {
63
+ ipv4 = v4mapped[1];
64
+ }
65
+ else if (v4hex) {
66
+ const hi = parseInt(v4hex[1], 16);
67
+ const lo = parseInt(v4hex[2], 16);
68
+ ipv4 = `${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`;
69
+ }
70
+ // Parse as IPv4 — handles decimal, but reject octal/hex by requiring standard dotted-quad
71
+ const parts = ipv4.split(".");
72
+ if (parts.length === 4) {
73
+ const nums = parts.map(p => {
74
+ if (!/^\d{1,3}$/.test(p))
75
+ return -1;
76
+ return parseInt(p, 10);
77
+ });
78
+ if (nums.every(n => n >= 0 && n <= 255)) {
79
+ const [a, b] = nums;
80
+ if (a === 0)
81
+ return true; // 0.0.0.0/8
82
+ if (a === 10)
83
+ return true; // 10.0.0.0/8
84
+ if (a === 127)
85
+ return true; // 127.0.0.0/8 (all loopback)
86
+ if (a === 172 && b >= 16 && b <= 31)
87
+ return true; // 172.16.0.0/12
88
+ if (a === 192 && b === 168)
89
+ return true; // 192.168.0.0/16
90
+ if (a === 169 && b === 254)
91
+ return true; // 169.254.0.0/16 link-local
92
+ if (a === 100 && b >= 64 && b <= 127)
93
+ return true; // 100.64.0.0/10 CGNAT
94
+ }
95
+ }
96
+ // Reject non-standard IP formats (octal 0177.0.0.1, hex 0x7f000001, decimal 2130706433)
97
+ // If it looks like a number or has 0x/0 prefix, block it
98
+ if (/^0x[0-9a-f]+$/i.test(clean) || /^0\d+$/.test(clean) || /^\d{4,}$/.test(clean))
99
+ return true;
100
+ return false;
101
+ }
50
102
  function isAllowedUrl(url) {
51
103
  try {
52
104
  const parsed = new URL(url);
@@ -54,32 +106,22 @@ function isAllowedUrl(url) {
54
106
  if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
55
107
  return false;
56
108
  const hostname = parsed.hostname.toLowerCase();
57
- // Block localhost and loopback
58
- if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]")
109
+ // Block localhost variants
110
+ if (hostname === "localhost" || hostname === "localhost.localdomain")
59
111
  return false;
60
- // Block .internal and .local TLDs
61
- if (hostname.endsWith(".internal") || hostname.endsWith(".local"))
112
+ // Block .internal, .local, .arpa TLDs
113
+ if (hostname.endsWith(".internal") || hostname.endsWith(".local") || hostname.endsWith(".arpa"))
114
+ return false;
115
+ // Block private/loopback IPs (covers 0.0.0.0, 127.x, 10.x, 172.16-31.x, 192.168.x, ::1, etc.)
116
+ if (isPrivateIP(hostname))
117
+ return false;
118
+ // Block bracketed IPv6
119
+ if (hostname.startsWith("[") && isPrivateIP(hostname))
62
120
  return false;
63
- // Block private IP ranges
64
- const ipParts = hostname.split(".").map(Number);
65
- if (ipParts.length === 4 && ipParts.every(p => Number.isFinite(p))) {
66
- // 10.0.0.0/8
67
- if (ipParts[0] === 10)
68
- return false;
69
- // 172.16.0.0/12
70
- if (ipParts[0] === 172 && ipParts[1] >= 16 && ipParts[1] <= 31)
71
- return false;
72
- // 192.168.0.0/16
73
- if (ipParts[0] === 192 && ipParts[1] === 168)
74
- return false;
75
- // 169.254.0.0/16 (link-local)
76
- if (ipParts[0] === 169 && ipParts[1] === 254)
77
- return false;
78
- }
79
121
  return true;
80
122
  }
81
123
  catch {
82
- return false; // Malformed URL
124
+ return false;
83
125
  }
84
126
  }
85
127
  // ─── Channel Senders ─────────────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prism-mcp-server",
3
- "version": "15.7.2",
3
+ "version": "15.7.4",
4
4
  "mcpName": "io.github.dcostenco/prism-coder",
5
5
  "description": "Prism Coder — Cognitive memory + tool-calling intelligence for AI agents. Mind Palace persistent memory (BFCL Gold Certified, 100% Tool-Call Accuracy, 54 Agent Skills, Zero-Search HDC/HRR retrieval, HIPAA-hardened local-first storage, SLERP-optimized GRPO alignment) plus the prism-coder:7b / 14b open-weights LLM fleet.",
6
6
  "module": "index.ts",