prism-mcp-server 20.8.1 → 20.8.2

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
@@ -116,6 +116,19 @@ or by re-enabling after each run.
116
116
  <details>
117
117
  <summary>Release history (optional)</summary>
118
118
 
119
+ ## What's New in v20.8.2
120
+
121
+ - **Skill delivery now admits failure instead of hiding it.** A filesystem
122
+ permission edge case (a umask stripping the owner-execute bit) could leave
123
+ skill sync writing nothing while reporting itself current — measured at nine
124
+ days on a real machine. Broken managed directories are repaired in place,
125
+ every directory is created umask-proof, and the repair path refuses symlinks
126
+ via an `O_NOFOLLOW` descriptor.
127
+ - **A stale install tells you at startup.** Prism now tracks the generation
128
+ that actually reached disk separately from the one the database accepted; if
129
+ they diverge, the startup banner says so in a warning placed where display
130
+ truncation cannot cut it. A successful sync clears it automatically.
131
+
119
132
  ## What's New in v20.7 – v20.8.0
120
133
 
121
134
  - **First run proves the memory instead of describing it** — `session_bootstrap`
@@ -608,10 +621,10 @@ for manual configuration and host-specific paths.
608
621
  **Optional — local model fleet** for offline tool-routing. Pull whichever fits your hardware:
609
622
 
610
623
  ```bash
611
- ollama pull dcostenco/prism-coder:2b # 2.3 GB · mobile / lightweight (99.1% routing accuracy)
612
- ollama pull dcostenco/prism-coder:4b # 3.4 GB · verifier (100% accuracy)
613
- ollama pull dcostenco/prism-coder:9b # 5.8 GB · default router (100% accuracy, Qwen3.5)
614
- ollama pull dcostenco/prism-coder:27b # 16 GB · complex tasks (100% accuracy)
624
+ ollama pull dcostenco/prism-coder:2b # 2.3 GB · mobile / lightweight (99.1% on our routing suite)
625
+ ollama pull dcostenco/prism-coder:4b # 3.4 GB · verifier (100% on our routing suite)
626
+ ollama pull dcostenco/prism-coder:9b # 5.8 GB · default router (100% on our routing suite, Qwen3.5)
627
+ ollama pull dcostenco/prism-coder:27b # 16 GB · complex tasks (100% on our routing suite)
615
628
  ```
616
629
 
617
630
  Prism detects both the namespaced (`dcostenco/prism-coder:9b`) and bare (`prism-coder:9b`) Ollama tags automatically.
@@ -632,6 +645,23 @@ Every conversation feeds a persistent store. The next session loads the right co
632
645
 
633
646
  The dashboard shows your current project state, pending TODOs, intent health, and a neural knowledge graph — all built automatically from your agent sessions.
634
647
 
648
+ ### Export — read the record outside the agent
649
+
650
+ `session_export_memory` writes your memory out as plain files you can read,
651
+ diff, and commit. Nothing goes through a model to produce it.
652
+
653
+ ```
654
+ markdown human-readable — drop it in a PR to show what the agent actually did
655
+ json machine-readable — import into another Prism instance
656
+ vault zipped Markdown with YAML frontmatter and [[wikilinks]] (Obsidian, Logseq)
657
+ ```
658
+
659
+ This is the surface to reach for when you want to answer "did the agent verify
660
+ this, or is it claiming it did?" — the export is a record you review after the
661
+ fact, in a diff or a pull request, rather than a live view you have to go and
662
+ open. The same data is available from the dashboard's **Export ZIP** and
663
+ **Export Vault** buttons.
664
+
635
665
  ### Knowledge Graph — semantic + keyword + graph search
636
666
 
637
667
  Ask "what did I decide about the auth flow last month?" and get an answer with citations, combining vector similarity, full-text search, and graph traversal.
@@ -741,7 +771,7 @@ air-gap. **Enterprise** includes a HIPAA Business Associate Agreement.
741
771
 
742
772
  ## Models
743
773
 
744
- The `prism-coder` fleet uses Qwen3.5 for MCP tool-routing AND general inference. The 9B and 27B are fine-tuned with LoRA (r=128, all 64 layers including DeltaNet); the 2B and 4B use stock Qwen3.5-4B at different quantization levels. The 27B scored 100% on BFCL function-calling and 100% on an internal 15-problem coding eval at $0 inference cost.
774
+ The `prism-coder` fleet uses Qwen3.5 for MCP tool-routing AND general inference. The 9B and 27B are fine-tuned with LoRA (r=128, all 64 layers including DeltaNet); the 2B and 4B use stock Qwen3.5-4B at different quantization levels. The 27B scored 100% on our internal 115-case tool-routing suite and 100% on an internal 15-problem coding eval, at $0 inference cost. These are self-run evaluations, not [BFCL](https://gorilla.cs.berkeley.edu/leaderboard.html) leaderboard submissions.
745
775
 
746
776
  `prism_infer` supports three modes: `route` (tool routing, fast, nothink), `chat` (conversation with thinking), and `code` (code generation with thinking). In chat/code modes, the model uses `<think>` blocks for chain-of-thought reasoning, which are stripped before the response is served. If the local model fails a quality gate (empty, think-only, or truncated), paid tiers automatically escalate to Gemini 3.6 Flash via the Synalux portal.
747
777
 
@@ -753,13 +783,17 @@ draft that may need correction—to Synalux for authenticated deterministic
753
783
  correction. Advertised custom host tools remain local. Set
754
784
  `route_guard: "local"` for a fully on-device route path.
755
785
 
756
- | Model | Ollama tag | Size | [BFCL](https://gorilla.cs.berkeley.edu/leaderboard.html) Accuracy | Role | Automatic routing tier |
786
+ | Model | Ollama tag | Size | Routing accuracy¹ | Role | Automatic routing tier |
757
787
  |---|---|---|---|---|---|
758
788
  | Qwen3.5-4B Q3_K_M | `prism-coder:2b` | 2.3 GB | 99.1% × 3 seeds | iPhone / mobile first gate | Free |
759
789
  | Qwen3.5-4B Q4_K_M | `prism-coder:4b` | 3.4 GB | 100% × 3 seeds | Verifier | Free |
760
790
  | Qwen3.5-9B (LoRA) | `prism-coder:9b` | 5.8 GB | 100% × 3 seeds | Default router | Standard+ |
761
791
  | Qwen3.5-27B (LoRA) | `prism-coder:27b` | 16 GB | 100% × 3 seeds | Quality tier (DeltaNet, 28.5 tok/s) | Advanced+ |
762
792
 
793
+ ¹ Self-run on a narrow 115-case MCP tool-selection suite, 3 seeds. It says these
794
+ models pick the right tool on our own eval, nothing more — not a general capability
795
+ measure, and not an independent benchmark result. Full methodology caveats below.
796
+
763
797
  These tiers control automatic `prism_infer` selection, not Ollama itself. Any
764
798
  user can run any downloaded on-device model directly through Ollama on every
765
799
  plan.
@@ -20,7 +20,8 @@
20
20
  * side.
21
21
  */
22
22
  import { createHash, randomUUID } from "node:crypto";
23
- import { link, lstat, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
23
+ import { mkdirUsable, repairOwnerAccess } from "./utils/usableDirectory.js";
24
+ import { link, lstat, readFile, rename, rm, writeFile } from "node:fs/promises";
24
25
  import { homedir } from "node:os";
25
26
  import { join } from "node:path";
26
27
  const OWNER = "prism-mcp";
@@ -306,11 +307,14 @@ function isErrno(error, code) {
306
307
  * mid-run leaves files that the next run re-proves ownership of by digest.
307
308
  */
308
309
  export async function materializeAgentDefinitions(section, targetDir, render = renderClaudeAgent) {
309
- await mkdir(targetDir, { recursive: true, mode: 0o700 });
310
+ // Same umask trap as the skill roots: a masked mkdir leaves a directory
311
+ // nothing can enter, and every write below it then fails.
312
+ await mkdirUsable(targetDir);
310
313
  const rootStat = await lstat(targetDir);
311
314
  if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) {
312
315
  throw new Error("agent root must be a real directory");
313
316
  }
317
+ await repairOwnerAccess(targetDir, rootStat.mode);
314
318
  const indexPath = join(targetDir, INDEX);
315
319
  const index = await readIndex(indexPath);
316
320
  const owned = index?.files ?? {};
@@ -1,12 +1,23 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { constants as fsConstants } from "node:fs";
3
- import { access, lstat, mkdir, mkdtemp, open, readFile, readdir, readlink, realpath, rename, rm, writeFile, } from "node:fs/promises";
3
+ import { access, lstat, mkdtemp, open, readFile, readdir, readlink, realpath, rename, rm, writeFile, } from "node:fs/promises";
4
4
  import { homedir } from "node:os";
5
5
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
6
- import { applyManagedSkillManifest, getSetting, refreshConfigStorageCache, } from "./storage/configStorage.js";
6
+ import { applyManagedSkillManifest, getSetting, refreshConfigStorageCache, setSetting, } from "./storage/configStorage.js";
7
7
  import { materializeAgentDefinitions, renderClaudeAgent, renderCodexAgent, renderGeminiAgent, resolveClaudeAgentsDir, resolveCodexAgentsDir, resolveGeminiAgentsDir, validateAgentSection, } from "./agentManifestSync.js";
8
8
  import { FREE_NATIVE_SKILL_NAMES, REQUIRED_NATIVE_SKILL_NAMES } from "./tools/skillRouting.js";
9
9
  import { getSynaluxJwt, invalidateSynaluxJwt } from "./utils/synaluxJwt.js";
10
+ import { mkdirUsable, repairOwnerAccess } from "./utils/usableDirectory.js";
11
+ /**
12
+ * Generation whose files actually reached disk, as opposed to the generation
13
+ * the config DB accepted. They diverge exactly when materialization fails, and
14
+ * that divergence is what made the 2026-08-10 outage invisible: the DB half
15
+ * commits first (deliberately -- committed names are what let a crashed run
16
+ * prune obsolete skills offline on restart), so the client reported the new
17
+ * generation while every managed root stayed frozen for nine days. Only a
18
+ * completed materialization advances this key.
19
+ */
20
+ export const MATERIALIZED_GENERATION_KEY = "skill_manifest:materialized_generation";
10
21
  const OWNER = "prism-skill-sync-v1";
11
22
  const MARKER = ".prism-managed.json";
12
23
  const INDEX = ".prism-managed-skills.json";
@@ -279,13 +290,13 @@ async function matchesIncomingSkill(path, skill) {
279
290
  }
280
291
  async function stageSkill(root, skill, generation) {
281
292
  const target = join(root, skill.name);
282
- await mkdir(target, { recursive: true, mode: 0o700 });
293
+ await mkdirUsable(target);
283
294
  const digests = Object.create(null);
284
295
  for (const [file, encoded] of Object.entries(skill.files)) {
285
296
  const path = resolve(target, file);
286
297
  if (!path.startsWith(`${target}${sep}`))
287
298
  throw new Error(`unsafe resolved path: ${file}`);
288
- await mkdir(dirname(path), { recursive: true, mode: 0o700 });
299
+ await mkdirUsable(dirname(path));
289
300
  await writeFile(path, decodeFile(encoded), { mode: 0o600 });
290
301
  digests[file] = encoded.digest;
291
302
  }
@@ -295,10 +306,23 @@ async function stageSkill(root, skill, generation) {
295
306
  }
296
307
  async function ensureRealDirectory(path) {
297
308
  if (!(await exists(path)))
298
- await mkdir(path, { recursive: true, mode: 0o700 });
309
+ await mkdirUsable(path);
299
310
  const stat = await lstat(path);
300
311
  if (!stat.isDirectory() || stat.isSymbolicLink())
301
312
  throw new Error(`managed path must be a real directory: ${path}`);
313
+ // Existing-but-unusable is the failure this repairs. mkdir's mode is masked
314
+ // by the creating process's umask, so a umask carrying the owner-execute bit
315
+ // (0o100) yields drw------- — a directory nothing can enter. Every later
316
+ // mkdtemp then throws EACCES, materialization aborts, and the run reports
317
+ // "partial" to stderr the host does not surface.
318
+ //
319
+ // Measured 2026-08-10: this exact directory sat at 0o600 for NINE days.
320
+ // The config DB half of each sync committed the new generation and digests
321
+ // while the file half never ran, so the client reported itself current while
322
+ // every managed skill root stayed frozen at its 2026-08-01 content. Checking
323
+ // "is a real directory" without checking "can I use it" made the outage
324
+ // permanent — nothing in the loop ever repaired the mode.
325
+ await repairOwnerAccess(path, stat.mode);
302
326
  }
303
327
  async function removeExpiredTransactions(base) {
304
328
  const cutoff = Date.now() - TRANSACTION_RETENTION_MS;
@@ -517,10 +541,12 @@ async function readCommittedManifestNames() {
517
541
  }
518
542
  }
519
543
  async function materializeNative(manifest, agentsSkillsDir, hooks) {
520
- await mkdir(agentsSkillsDir, { recursive: true, mode: 0o700 });
544
+ await mkdirUsable(agentsSkillsDir);
521
545
  const rootStat = await lstat(agentsSkillsDir);
522
546
  if (!rootStat.isDirectory() || rootStat.isSymbolicLink())
523
547
  throw new Error("native skills root must be a real directory");
548
+ // The readdir immediately below is the first thing an unusable root breaks.
549
+ await repairOwnerAccess(agentsSkillsDir, rootStat.mode);
524
550
  await quarantineLegacyDiscoveryArtifacts(agentsSkillsDir);
525
551
  // Transaction content must remain outside the native discovery root: some
526
552
  // hosts recursively scan it and would otherwise discover staged/pruned paid
@@ -529,6 +555,10 @@ async function materializeNative(manifest, agentsSkillsDir, hooks) {
529
555
  await ensureRealDirectory(transactionBase);
530
556
  await removeExpiredTransactions(transactionBase);
531
557
  const transactionRoot = await mkdtemp(join(transactionBase, "txn-"));
558
+ // mkdtemp is umask-masked too, and nothing else revisits this path. A
559
+ // persistent restrictive umask therefore produced a REPAIRED base holding a
560
+ // brand-new unusable txn-* directory -- the original outage one level deeper.
561
+ await repairOwnerAccess(transactionRoot, (await lstat(transactionRoot)).mode);
532
562
  const stageRoot = join(transactionRoot, "stage");
533
563
  const backupRoot = join(transactionRoot, "backup");
534
564
  await ensureRealDirectory(stageRoot);
@@ -771,10 +801,15 @@ async function fetchManifest(options) {
771
801
  return manifest;
772
802
  }
773
803
  async function acquireSyncLock(agentsSkillsDir, waitMs = LOCK_WAIT_MS) {
774
- await mkdir(agentsSkillsDir, { recursive: true, mode: 0o700 });
804
+ await mkdirUsable(agentsSkillsDir);
775
805
  const rootStat = await lstat(agentsSkillsDir);
776
806
  if (!rootStat.isDirectory() || rootStat.isSymbolicLink())
777
807
  throw new Error("native skills root must be a real directory");
808
+ // Same repair as ensureRealDirectory. This site takes the lock before any
809
+ // materialization, so a root that exists without owner rwx fails here first —
810
+ // mkdir no-ops on an existing directory and every open below throws EACCES,
811
+ // with nothing in the loop restoring the mode.
812
+ await repairOwnerAccess(agentsSkillsDir, rootStat.mode);
778
813
  const lockPath = join(agentsSkillsDir, ".prism-sync.lock");
779
814
  const deadline = Date.now() + Math.max(0, waitMs);
780
815
  const token = randomUUID();
@@ -936,6 +971,10 @@ export async function synchronizeSkillManifest(options = {}) {
936
971
  native.conflicts.push(...outcome.conflicts);
937
972
  }
938
973
  const status = native.installed.length || native.updated.length || native.pruned.length ? "applied" : "unchanged";
974
+ // Reached only when every native root materialized. A failure above throws
975
+ // past this point, leaving the previous value so the mismatch persists and
976
+ // stays visible until a sync genuinely succeeds.
977
+ await setSetting(MATERIALIZED_GENERATION_KEY, manifest.generation);
939
978
  return {
940
979
  status,
941
980
  tier: manifest.tier,
@@ -1,7 +1,8 @@
1
1
  import { createClient } from "@libsql/client";
2
2
  import { resolve, dirname } from "path";
3
3
  import { homedir } from "os";
4
- import { existsSync, mkdirSync } from "fs";
4
+ import { existsSync } from "fs";
5
+ import { mkdirUsableSync } from "../utils/usableDirectory.js";
5
6
  const PROTO_KEYS = new Set(["__proto__", "constructor", "prototype"]);
6
7
  // We use a small, dedicated DB just for configuration settings.
7
8
  // This solves the chicken-and-egg problem: we need to know WHICH
@@ -33,7 +34,10 @@ function getClient() {
33
34
  // and libSQL throws SQLITE_CANTOPEN (error 14) without it.
34
35
  const dir = dirname(CONFIG_PATH);
35
36
  if (!existsSync(dir)) {
36
- mkdirSync(dir, { recursive: true });
37
+ // Not plain mkdirSync: recursive creation is umask-masked at every level,
38
+ // and this directory holds the entitlement snapshot, so it must be private
39
+ // and enterable regardless of the umask Prism happens to inherit.
40
+ mkdirUsableSync(dir);
37
41
  }
38
42
  configClient = createClient({
39
43
  url: `file:${CONFIG_PATH}`,
@@ -31,6 +31,7 @@ import { toKeywordArray } from "../utils/keywordExtractor.js";
31
31
  import { getLLMProvider } from "../utils/llm/factory.js";
32
32
  import { getCurrentGitState, getGitDrift } from "../utils/git.js";
33
33
  import { getSetting, setSetting, getAllSettings, refreshConfigStorageCache } from "../storage/configStorage.js";
34
+ import { MATERIALIZED_GENERATION_KEY } from "../skillManifestSync.js";
34
35
  import { mergeHandoff, dbToHandoffSchema, sanitizeForMerge } from "../utils/crdtMerge.js";
35
36
  import { resolveProject } from "../utils/projectResolver.js";
36
37
  import { isRecoverableStartupStorageError, LOCAL_STARTUP_FALLBACK_NOTICE, } from "../utils/startupRecovery.js";
@@ -205,10 +206,34 @@ function parseNativeSkillNames(value) {
205
206
  }
206
207
  async function resolveNativeSkillManifestSnapshot(syncResult) {
207
208
  const { FREE_NATIVE_SKILL_NAMES, REQUIRED_NATIVE_SKILL_NAMES } = await import("./skillRouting.js");
208
- const [storedNamesValue, storedTierValue] = await Promise.all([
209
+ const [storedNamesValue, storedTierValue, committedGeneration, materializedGeneration] = await Promise.all([
209
210
  getSetting("skill_manifest:names", "[]"),
210
211
  getSetting("skill_manifest:tier", ""),
212
+ getSetting("skill_manifest:generation", ""),
213
+ // The exported constant, not a copied literal: a key rename on either
214
+ // side would otherwise leave this read returning "" forever -- warning
215
+ // permanently silent -- with both sides' tests still green, because each
216
+ // asserts against its own copy of the string.
217
+ getSetting(MATERIALIZED_GENERATION_KEY, ""),
211
218
  ]);
219
+ // The DB half of a sync commits before files are written, by design: the
220
+ // committed names are what let a crashed run prune obsolete skills offline on
221
+ // restart. The cost is that this snapshot can describe an entitlement whose
222
+ // files never landed. A DIVERGENCE here is that exact state, and it persists
223
+ // across sessions -- a later sync reports "unchanged" because nothing needed
224
+ // writing, while the roots agents actually read stay frozen. Unreported, it
225
+ // ran for nine days on 2026-08-10.
226
+ // An EMPTY marker is "never recorded", not "never delivered": every install
227
+ // that predates the marker has a committed generation and no marker at all,
228
+ // and an offline user's failed fetch must not scream STALE at them on
229
+ // upgrade day. A false alarm here trains people to ignore the line, which is
230
+ // how the real one gets waved through. The fresh-install failure case is
231
+ // still covered -- that run's own syncStatus is "partial" and the
232
+ // validated-partial branch already reports materialization incomplete.
233
+ const undeliveredGeneration = Boolean(committedGeneration) && Boolean(materializedGeneration) &&
234
+ committedGeneration !== materializedGeneration
235
+ ? committedGeneration
236
+ : "";
212
237
  const partialNamesInput = syncResult.status === "partial" && Array.isArray(syncResult.entitledNames)
213
238
  ? syncResult.entitledNames
214
239
  : null;
@@ -245,6 +270,7 @@ async function resolveNativeSkillManifestSnapshot(syncResult) {
245
270
  tier,
246
271
  source,
247
272
  syncStatus: syncResult.status,
273
+ undeliveredGeneration,
248
274
  conflicts: [...new Set(syncResult.conflicts)],
249
275
  };
250
276
  }
@@ -293,8 +319,24 @@ async function buildNativeSystemReadyBlock(snapshot, depth) {
293
319
  `directory out of the native skills folder and rerun \`prism connect\` ` +
294
320
  `(or a session bootstrap) to reinstall the managed copy.`
295
321
  : "";
322
+ // Durable, not per-run: a later sync reports "unchanged" while the roots stay
323
+ // frozen from an earlier failed materialization. This line is the difference
324
+ // between that outage being visible on the next startup and it running for
325
+ // nine days.
326
+ //
327
+ // Rendered directly UNDER the header, never appended at the tail:
328
+ // capNativeStartupText keeps the head and cuts the tail, so a tail-placed
329
+ // warning is the first thing truncated at capped depths -- observed live in
330
+ // the divergence simulation (STALE was line 19 of 20). The most important
331
+ // line goes where truncation cannot reach it.
332
+ const undeliveredWarning = snapshot.undeliveredGeneration
333
+ ? `\n> - ⚠️ **Skill files are STALE:** the entitlement DB is at generation ` +
334
+ `\`${snapshot.undeliveredGeneration.slice(0, 12)}…\` but its files never finished ` +
335
+ `reaching the skill roots. Agents are reading older skills. Run a sync ` +
336
+ `(restart the host or \`prism connect\`) and report this if it persists.`
337
+ : "";
296
338
  if (snapshot.source === "validated-partial") {
297
- return `> **Prism System Ready**\n>\n` +
339
+ return `> **Prism System Ready**\n>` + undeliveredWarning + `\n` +
298
340
  `> - 🪪 **Subscription tier:** ${snapshot.tier}\n` +
299
341
  `> - 📦 **Entitled skills (materialization incomplete):** ${snapshot.names.length}\n` +
300
342
  `> - 📚 **Core/protected entitlements:** ${formatBoundedSkillNames(coreSkills, "entitled")}\n` +
@@ -306,7 +348,7 @@ async function buildNativeSystemReadyBlock(snapshot, depth) {
306
348
  freeTierUpgradeLine(snapshot.tier);
307
349
  }
308
350
  if (snapshot.source === "tier-fallback") {
309
- return `> **Prism System Ready**\n>\n` +
351
+ return `> **Prism System Ready**\n>` + undeliveredWarning + `\n` +
310
352
  `> - 🪪 **Subscription tier:** ${snapshot.tier}\n` +
311
353
  `> - 🛡️ **Fallback skill names:** ${formatBoundedSkillNames(snapshot.names, "fallback")}\n` +
312
354
  `> - 🧠 **Context depth:** ${depth}\n` +
@@ -314,7 +356,7 @@ async function buildNativeSystemReadyBlock(snapshot, depth) {
314
356
  conflictWarning +
315
357
  freeTierUpgradeLine(snapshot.tier);
316
358
  }
317
- return `> **Prism System Ready**\n>\n` +
359
+ return `> **Prism System Ready**\n>` + undeliveredWarning + `\n` +
318
360
  `> - 🪪 **Subscription tier:** ${snapshot.tier}\n` +
319
361
  `> - 📦 **Provisioned skills:** ${snapshot.names.length}\n` +
320
362
  `> - 📚 **Core/protected skills provisioned:** ${formatBoundedSkillNames(coreSkills, "provisioned")}\n` +
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Directory creation and repair that a restrictive umask cannot defeat.
3
+ *
4
+ * WHY THIS EXISTS (2026-08-10)
5
+ * Skill delivery died silently for nine days. `.prism-skill-transactions` had
6
+ * been created with `mkdir(..., { mode: 0o700 })`, but mkdir's mode is masked by
7
+ * the process umask: a umask carrying the owner-execute bit yields drw-------,
8
+ * a directory that can be read and written but never entered. Every subsequent
9
+ * mkdtemp threw EACCES, materialization aborted, and — because the config DB
10
+ * half of a sync commits before the file half — the client went on reporting the
11
+ * new generation while every managed skill root stayed frozen at older content.
12
+ *
13
+ * The first fix repaired a directory that was already broken and left the cause
14
+ * alone: a umask that is still restrictive masks every directory created after
15
+ * it, so a repaired base simply gained a brand-new unusable child. Node exposes
16
+ * no per-call umask, so the only portable defeat is to create one level at a
17
+ * time and repair what we just made.
18
+ *
19
+ * These helpers are shared rather than duplicated because the same trap exists
20
+ * anywhere Prism creates a private directory — skill roots, agent roots, and the
21
+ * config DB's parent — and a guard applied at two of three sites is not a guard.
22
+ */
23
+ import { constants as fsConstants, chmodSync, lstatSync, mkdirSync } from "node:fs";
24
+ import { chmod, lstat, mkdir, open } from "node:fs/promises";
25
+ import { isAbsolute, sep } from "node:path";
26
+ /** Mode every Prism-managed directory is created with and repaired to. */
27
+ export const MANAGED_DIR_MODE = 0o700;
28
+ function isErrno(error, code) {
29
+ return typeof error === "object" && error !== null && "code" in error
30
+ && error.code === code;
31
+ }
32
+ /**
33
+ * Restore owner rwx on a managed directory that exists but cannot be entered.
34
+ *
35
+ * Restores 0o700 EXACTLY rather than OR-ing owner bits onto whatever is there.
36
+ * These directories hold entitled skill content and pre-rollback backups;
37
+ * repairing 0o066 to 0o766 would "fix" an outage by leaving them world-readable.
38
+ *
39
+ * Prism-managed directories REQUIRE owner rwx, so a deliberate sharing mode that
40
+ * withholds it — 0o670, say — is discarded rather than preserved. Conventional
41
+ * sharing is unaffected: this only fires on a directory the owner cannot use,
42
+ * which is broken by any definition. A 0o770 directory is left untouched.
43
+ *
44
+ * Applies the change through an open descriptor, not the pathname. A pathname
45
+ * chmod re-resolves the path, so a process able to swap the directory for a
46
+ * symlink between the check and the change could redirect the permission change
47
+ * somewhere else entirely. fchmod acts on the object already opened.
48
+ *
49
+ * POSIX only. On Windows chmod maps to the read-only attribute alone and lstat
50
+ * reports 0o666 for every writable directory, so the condition can never be
51
+ * satisfied and the failure being repaired has no analogue there.
52
+ */
53
+ export async function repairOwnerAccess(path, mode) {
54
+ if (process.platform === "win32")
55
+ return;
56
+ if ((mode & MANAGED_DIR_MODE) === MANAGED_DIR_MODE)
57
+ return;
58
+ let handle;
59
+ try {
60
+ // O_NOFOLLOW: a symlink at this path must fail the open rather than hand us
61
+ // a descriptor to whatever it points at.
62
+ handle = await open(path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
63
+ }
64
+ catch {
65
+ // Opening a directory needs owner-read, so the very modes this exists to
66
+ // repair (0o000, 0o300) land here — as does any symlink, via O_NOFOLLOW.
67
+ // An unguarded pathname chmod at this point re-opens the symlink hole: a
68
+ // link to an UNREADABLE file skipped the descriptor path entirely and got
69
+ // its target chmodded to 0o700. Proven with a live attack against the
70
+ // built helper during review, not hypothesized. lstat separates the two
71
+ // cases; the remaining lstat→chmod race is confined to unreadable real
72
+ // directories inside the user's own home.
73
+ const entry = await lstat(path);
74
+ if (!entry.isDirectory() || entry.isSymbolicLink()) {
75
+ throw new Error(`managed path is not a directory: ${path}`);
76
+ }
77
+ await chmod(path, MANAGED_DIR_MODE);
78
+ return;
79
+ }
80
+ try {
81
+ const opened = await handle.stat();
82
+ if (!opened.isDirectory())
83
+ throw new Error(`managed path is not a directory: ${path}`);
84
+ await handle.chmod(MANAGED_DIR_MODE);
85
+ }
86
+ finally {
87
+ await handle.close();
88
+ }
89
+ }
90
+ /**
91
+ * Create a directory, and any missing ancestors, that the owner can enter.
92
+ *
93
+ * `recursive: true` applies the same masked mode to every level it creates, so
94
+ * under a hostile umask the first new ancestor is already untraversable and the
95
+ * call fails partway with EACCES before anything can repair it.
96
+ *
97
+ * Repairs ONLY directories this call creates. A pre-existing ancestor — $HOME
98
+ * and everything above it — keeps exactly the mode the user configured; silently
99
+ * widening those would be a worse bug than the one being fixed.
100
+ */
101
+ export async function mkdirUsable(target) {
102
+ if (process.platform === "win32") {
103
+ await mkdir(target, { recursive: true, mode: MANAGED_DIR_MODE });
104
+ return;
105
+ }
106
+ const segments = target.split(sep).filter(Boolean);
107
+ let current = isAbsolute(target) ? "" : ".";
108
+ for (const segment of segments) {
109
+ current = current === "" ? `${sep}${segment}` : `${current}${sep}${segment}`;
110
+ try {
111
+ await mkdir(current, { mode: MANAGED_DIR_MODE });
112
+ }
113
+ catch (error) {
114
+ if (isErrno(error, "EEXIST"))
115
+ continue; // not ours — do not touch its mode
116
+ throw error;
117
+ }
118
+ await repairOwnerAccess(current, (await lstat(current)).mode);
119
+ }
120
+ }
121
+ /**
122
+ * Synchronous twin of {@link mkdirUsable}, for callers on a sync path.
123
+ *
124
+ * The config DB's parent is created before any await is possible, and it holds
125
+ * the entitlement snapshot, so it needs the same umask defeat and the same
126
+ * private mode. Kept beside the async version deliberately: two implementations
127
+ * of this rule in different files is how one of them ends up wrong.
128
+ */
129
+ export function mkdirUsableSync(target) {
130
+ if (process.platform === "win32") {
131
+ mkdirSync(target, { recursive: true, mode: MANAGED_DIR_MODE });
132
+ return;
133
+ }
134
+ const segments = target.split(sep).filter(Boolean);
135
+ let current = isAbsolute(target) ? "" : ".";
136
+ for (const segment of segments) {
137
+ current = current === "" ? `${sep}${segment}` : `${current}${sep}${segment}`;
138
+ try {
139
+ mkdirSync(current, { mode: MANAGED_DIR_MODE });
140
+ }
141
+ catch (error) {
142
+ if (isErrno(error, "EEXIST"))
143
+ continue; // not ours — do not touch its mode
144
+ throw error;
145
+ }
146
+ if ((lstatSync(current).mode & MANAGED_DIR_MODE) !== MANAGED_DIR_MODE) {
147
+ chmodSync(current, MANAGED_DIR_MODE);
148
+ }
149
+ }
150
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prism-mcp-server",
3
- "version": "20.8.1",
3
+ "version": "20.8.2",
4
4
  "mcpName": "io.github.dcostenco/prism-coder",
5
5
  "description": "Persistent session memory for AI coding agents that never leaves your machine \u2014 including the on-device model that reasons over it. Restores your prior decisions, open TODOs, and changed files across sessions; adds associative recall of related past work, semantic drift detection, and local inference. Local-first by default. Works with Claude Code, Cursor, and Codex.",
6
6
  "module": "index.ts",
@@ -82,7 +82,8 @@
82
82
  "ip-address": "^10.2.0",
83
83
  "protobufjs": "^7.6.5",
84
84
  "sharp": "^0.35.0",
85
- "tar": "^7.5.19"
85
+ "tar": "^7.5.19",
86
+ "nanoid": "^3.3.17"
86
87
  },
87
88
  "dependencies": {
88
89
  "@anthropic-ai/sdk": "^0.92.0",