prism-mcp-server 19.2.4 → 19.2.5

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
@@ -412,6 +412,40 @@ prism_infer({
412
412
 
413
413
  Full TypeScript signatures live in [`src/tools/`](src/tools/); architecture in [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md).
414
414
 
415
+ ### `inference_metrics` — see your local-model usage on demand
416
+
417
+ Call `inference_metrics` anytime mid-session to see how many `prism_infer` calls ran locally vs cloud, with actual token counts:
418
+
419
+ ```
420
+ 📊 Inference Metrics — local-model delegation (this session):
421
+ Total calls: 5 — Local: 5 (100%) | Cloud: 0 (0%)
422
+ Tokens: 1,240 in + 380 out = 1,620 total
423
+ Avg latency: 420ms
424
+ By model:
425
+ prism-coder:27b: 3 calls, 1,100 tokens, avg 520ms
426
+ prism-coder:9b: 2 calls, 520 tokens, avg 270ms
427
+ ```
428
+
429
+ The same block also appears automatically in `session_save_ledger` and `session_save_handoff` responses at session end.
430
+
431
+ **Note:** This tracks `prism_infer` delegation only — not your host model's (Claude's) own token spend. For that, use Claude Code's `/cost` command.
432
+
433
+ ### Local-model delegation (opt-in)
434
+
435
+ By default, your AI agent (Claude, Cursor, etc.) handles everything itself. You can optionally enable delegation so the agent offloads cheap, verifiable sub-tasks to local Ollama models at $0:
436
+
437
+ ```bash
438
+ # Enable via Prism config
439
+ prism config set delegation_enabled true
440
+ ```
441
+
442
+ When enabled, the agent's task router may delegate qualifying work — bulk classification, field extraction, mechanical formatting — to `prism_infer` instead of using cloud tokens. The agent always verifies the result and redoes it itself if quality is degraded.
443
+
444
+ **Guardrails:**
445
+ - **Off by default** — enforced in code, not just convention
446
+ - **Never delegates:** code/text that ships to the user, security/safety logic, planning/reasoning, anything where a silent quality drop isn't obvious
447
+ - **Always verifies:** checks `quality_gate_failed` and `used_cloud` before trusting local output
448
+
415
449
  <details>
416
450
  <summary>How Prism survives context compaction</summary>
417
451
 
@@ -40,8 +40,8 @@ isSessionForgetMemoryArgs, // v3.1: TTL retention policy type guard
40
40
  isSessionSaveExperienceArgs, isSessionExportMemoryArgs, normalizeExportFormat, isSessionSaveImageArgs, isSessionViewImageArgs } from "./sessionMemoryDefinitions.js";
41
41
  // v4.2: File system access for knowledge_sync_rules
42
42
  import { writeFile } from "node:fs/promises";
43
- import { existsSync } from "node:fs";
44
- import { join } from "node:path";
43
+ import { existsSync, realpathSync, mkdirSync } from "node:fs";
44
+ import { join, sep } from "node:path";
45
45
  // v3.1: In-memory debounce lock for auto-compaction.
46
46
  // Prevents multiple concurrent Gemini compaction tasks for the same project
47
47
  // when many agents call session_save_ledger at the same time.
@@ -1448,7 +1448,13 @@ export async function sessionExportMemoryHandler(args) {
1448
1448
  // to select the correct .obsidian/.logseq sidecar config.
1449
1449
  const requestedFormat = rawFormat;
1450
1450
  const format = normalizeExportFormat(rawFormat);
1451
- // Validate output directory
1451
+ // Validate output directory — path traversal confinement (v4.3 security)
1452
+ const homeDir = process.env.HOME || process.env.USERPROFILE || "/tmp";
1453
+ const defaultExportDir = join(homeDir, ".prism-mcp", "exports");
1454
+ // Create default export directory if it doesn't exist
1455
+ if (!existsSync(defaultExportDir)) {
1456
+ mkdirSync(defaultExportDir, { recursive: true });
1457
+ }
1452
1458
  if (!existsSync(output_dir)) {
1453
1459
  return {
1454
1460
  content: [{
@@ -1458,6 +1464,72 @@ export async function sessionExportMemoryHandler(args) {
1458
1464
  isError: true,
1459
1465
  };
1460
1466
  }
1467
+ // Resolve symlinks and ".." to get the canonical path
1468
+ const resolvedDir = realpathSync(output_dir);
1469
+ // Build allow-list of safe export roots
1470
+ const allowedRoots = [
1471
+ defaultExportDir,
1472
+ process.cwd(),
1473
+ ];
1474
+ // Scratch root under tmp — but NOT the world-writable tmp root itself.
1475
+ // os.tmpdir() (typically /tmp, mode 1777) is readable and writable by every
1476
+ // local principal: allowing it as an export root would (a) leak unredacted
1477
+ // ledger/handoff contents to other users and (b) let a co-resident attacker
1478
+ // pre-plant a symlink at the predictable export filename and have writeFile
1479
+ // follow it (arbitrary-file overwrite). Confine to an owner-only subdir.
1480
+ const tmpExportDir = join(os.tmpdir(), ".prism-mcp", "exports");
1481
+ if (!existsSync(tmpExportDir)) {
1482
+ mkdirSync(tmpExportDir, { recursive: true, mode: 0o700 });
1483
+ }
1484
+ allowedRoots.push(realpathSync(tmpExportDir));
1485
+ if (process.env.PRISM_EXPORT_ROOT) {
1486
+ const exportRoot = process.env.PRISM_EXPORT_ROOT;
1487
+ if (existsSync(exportRoot)) {
1488
+ allowedRoots.push(realpathSync(exportRoot));
1489
+ }
1490
+ }
1491
+ // Reject sensitive directories (resolve each so macOS /etc → /private/etc matches)
1492
+ const sensitiveRoots = [
1493
+ join(homeDir, ".ssh"),
1494
+ join(homeDir, ".gnupg"),
1495
+ "/etc",
1496
+ "/var",
1497
+ "/etc/cron.d",
1498
+ "/etc/cron.daily",
1499
+ "/etc/sudoers.d",
1500
+ ].map(p => existsSync(p) ? realpathSync(p) : p);
1501
+ // Check allow-list first — explicitly configured roots take precedence
1502
+ const isAllowedRoot = (dir) => allowedRoots.some((root) => {
1503
+ const resolvedRoot = existsSync(root) ? realpathSync(root) : root;
1504
+ return dir === resolvedRoot || dir.startsWith(resolvedRoot + sep);
1505
+ });
1506
+ for (const sensitive of sensitiveRoots) {
1507
+ if (resolvedDir === sensitive || resolvedDir.startsWith(sensitive + sep)) {
1508
+ // Allow explicitly configured roots even under sensitive parents
1509
+ // (e.g. tmpExportDir under /private/var on macOS, or PRISM_EXPORT_ROOT)
1510
+ if (!isAllowedRoot(resolvedDir)) {
1511
+ return {
1512
+ content: [{
1513
+ type: "text",
1514
+ text: `Error: output_dir resolves to a sensitive system directory ("${resolvedDir}"). Export denied.`,
1515
+ }],
1516
+ isError: true,
1517
+ };
1518
+ }
1519
+ }
1520
+ }
1521
+ // Verify resolved path falls under an allowed root
1522
+ if (!isAllowedRoot(resolvedDir)) {
1523
+ return {
1524
+ content: [{
1525
+ type: "text",
1526
+ text: `Error: output_dir "${resolvedDir}" is outside allowed export roots. ` +
1527
+ `Allowed: ${allowedRoots.join(", ")}. ` +
1528
+ `Set PRISM_EXPORT_ROOT env var to add a custom root.`,
1529
+ }],
1530
+ isError: true,
1531
+ };
1532
+ }
1461
1533
  const storage = await getStorage();
1462
1534
  const exportedFiles = [];
1463
1535
  try {
@@ -1527,14 +1599,27 @@ export async function sessionExportMemoryHandler(args) {
1527
1599
  else {
1528
1600
  content = JSON.stringify(exportPayload, null, 2);
1529
1601
  }
1530
- if (format === "vault") {
1531
- await writeFile(outputPath, content);
1602
+ // Exclusive create (flag: "wx") refuses to write if the path already
1603
+ // exists — including a symlink pre-planted at the predictable export
1604
+ // name. On a genuine collision fall back to a timestamped unique name.
1605
+ let finalPath = outputPath;
1606
+ try {
1607
+ await writeFile(finalPath, content, { flag: "wx" });
1532
1608
  }
1533
- else {
1534
- await writeFile(outputPath, content, "utf-8");
1609
+ catch (e) {
1610
+ if (e && e.code === "EEXIST") {
1611
+ const dot = filename.lastIndexOf(".");
1612
+ const stem = dot > 0 ? filename.slice(0, dot) : filename;
1613
+ const extPart = dot > 0 ? filename.slice(dot) : "";
1614
+ finalPath = join(output_dir, `${stem}-${Date.now()}${extPart}`);
1615
+ await writeFile(finalPath, content, { flag: "wx" });
1616
+ }
1617
+ else {
1618
+ throw e;
1619
+ }
1535
1620
  }
1536
- exportedFiles.push(outputPath);
1537
- debugLog(`[session_export_memory] Wrote ${content.length} bytes to ${outputPath}`);
1621
+ exportedFiles.push(finalPath);
1622
+ debugLog(`[session_export_memory] Wrote ${content.length} bytes to ${finalPath}`);
1538
1623
  }
1539
1624
  const plural = exportedFiles.length > 1 ? "files" : "file";
1540
1625
  return {
@@ -11,6 +11,7 @@
11
11
  * Zero external dependencies — uses native fetch API.
12
12
  */
13
13
  import { debugLog } from "./logger.js";
14
+ import { lookup } from "node:dns/promises";
14
15
  // ─── Default Config ──────────────────────────────────────────
15
16
  const DEFAULT_CONFIG = {
16
17
  enabled: false,
@@ -99,7 +100,7 @@ function isPrivateIP(ip) {
99
100
  return true;
100
101
  return false;
101
102
  }
102
- function isAllowedUrl(url) {
103
+ async function isAllowedUrl(url) {
103
104
  try {
104
105
  const parsed = new URL(url);
105
106
  // Block non-HTTP(S) schemes
@@ -118,6 +119,18 @@ function isAllowedUrl(url) {
118
119
  // Block bracketed IPv6
119
120
  if (hostname.startsWith("[") && isPrivateIP(hostname))
120
121
  return false;
122
+ // Resolve hostname and reject if ANY address is private (closes
123
+ // attacker.example → 169.254.169.254 / 127.0.0.1 bypass)
124
+ try {
125
+ const addrs = await lookup(hostname, { all: true });
126
+ for (const { address } of addrs) {
127
+ if (isPrivateIP(address))
128
+ return false;
129
+ }
130
+ }
131
+ catch {
132
+ return false;
133
+ }
121
134
  return true;
122
135
  }
123
136
  catch {
@@ -126,7 +139,7 @@ function isAllowedUrl(url) {
126
139
  }
127
140
  // ─── Channel Senders ─────────────────────────────────────────
128
141
  async function sendWebhook(url, payload) {
129
- if (!isAllowedUrl(url)) {
142
+ if (!(await isAllowedUrl(url))) {
130
143
  debugLog(`Webhook URL blocked by SSRF policy: ${url}`);
131
144
  return false;
132
145
  }
@@ -135,6 +148,7 @@ async function sendWebhook(url, payload) {
135
148
  method: "POST",
136
149
  headers: { "Content-Type": "application/json" },
137
150
  body: JSON.stringify(payload),
151
+ redirect: "error",
138
152
  signal: AbortSignal.timeout(10_000),
139
153
  });
140
154
  return response.ok;
@@ -145,7 +159,7 @@ async function sendWebhook(url, payload) {
145
159
  }
146
160
  }
147
161
  async function sendSlack(webhookUrl, payload) {
148
- if (!isAllowedUrl(webhookUrl)) {
162
+ if (!(await isAllowedUrl(webhookUrl))) {
149
163
  debugLog(`Slack webhook URL blocked by SSRF policy: ${webhookUrl}`);
150
164
  return false;
151
165
  }
@@ -191,6 +205,7 @@ async function sendSlack(webhookUrl, payload) {
191
205
  method: "POST",
192
206
  headers: { "Content-Type": "application/json" },
193
207
  body: JSON.stringify(slackPayload),
208
+ redirect: "error",
194
209
  signal: AbortSignal.timeout(10_000),
195
210
  });
196
211
  return response.ok;
@@ -201,7 +216,7 @@ async function sendSlack(webhookUrl, payload) {
201
216
  }
202
217
  }
203
218
  async function sendEmail(endpoint, payload) {
204
- if (!isAllowedUrl(endpoint)) {
219
+ if (!(await isAllowedUrl(endpoint))) {
205
220
  debugLog(`Email endpoint URL blocked by SSRF policy: ${endpoint}`);
206
221
  return false;
207
222
  }
@@ -216,6 +231,7 @@ async function sendEmail(endpoint, payload) {
216
231
  project: payload.project,
217
232
  details: payload.details,
218
233
  }),
234
+ redirect: "error",
219
235
  signal: AbortSignal.timeout(10_000),
220
236
  });
221
237
  return response.ok;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prism-mcp-server",
3
- "version": "19.2.4",
3
+ "version": "19.2.5",
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, 114 Agent Skills, PHI Guard, Tier Enforcement, Prompt-Based Skill Routing, Zero-Search HDC/HRR retrieval, HRR Semantic Drift Detection across BCBA/Coding/AAC domains, HIPAA-hardened local-first storage, SLERP-optimized GRPO alignment) plus the prism-coder 1.7B–32B open-weights LLM fleet.",
6
6
  "module": "index.ts",
@@ -129,9 +129,10 @@
129
129
  "@modelcontextprotocol/sdk": "^1.27.1",
130
130
  "@mozilla/readability": "^0.6.0",
131
131
  "@opentelemetry/api": "^1.9.1",
132
- "@opentelemetry/exporter-trace-otlp-http": "^0.214.0",
133
- "@opentelemetry/resources": "^2.6.1",
134
- "@opentelemetry/sdk-trace-node": "^2.6.1",
132
+ "@opentelemetry/core": "^2.8.0",
133
+ "@opentelemetry/exporter-trace-otlp-http": "^0.219.0",
134
+ "@opentelemetry/resources": "^2.8.0",
135
+ "@opentelemetry/sdk-trace-node": "^2.8.0",
135
136
  "@opentelemetry/semantic-conventions": "^1.40.0",
136
137
  "@supabase/supabase-js": "^2.99.3",
137
138
  "cheerio": "^1.2.0",