mulmoclaude 2.8.0 → 2.8.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mulmoclaude",
3
- "version": "2.8.0",
3
+ "version": "2.8.1",
4
4
  "description": "MulmoClaude — GUI-chat with Claude Code + long-term memory. One command to start.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -30,7 +30,6 @@ import { log } from "../../system/logger/index.js";
30
30
  import { previewSnippet } from "../../utils/logPreview.js";
31
31
  import { publishFileChange } from "../../events/file-change.js";
32
32
  import { makeArtifactsFileOps } from "../../plugins/runtime.js";
33
- import { makeConfinedArtifactsFileOps } from "../../plugins/confined-file-ops.js";
34
33
  import { isAblated } from "../../system/env.js";
35
34
  import { executeKenteiScript } from "../../plugins/kentei-builtin.js";
36
35
  import { META as KENTEI_META } from "../../../src/plugins/manageKenteiScript/meta.js";
@@ -260,7 +259,7 @@ bindRoute(
260
259
  router,
261
260
  API_ROUTES.spreadsheet.create,
262
261
  wrapPluginExecute<SpreadsheetArgs, unknown>((req) => {
263
- const context: ToolContext & { files: { artifacts: FileOps } } = { files: { artifacts: makeConfinedArtifactsFileOps() } };
262
+ const context: ToolContext & { files: { artifacts: FileOps } } = { files: { artifacts: makeArtifactsFileOps() } };
264
263
  return executeSpreadsheet(context, req.body);
265
264
  }),
266
265
  );
@@ -1,22 +1,84 @@
1
- // FileOps that refuse a path whose real location (symlinks followed) leaves the scope root.
2
- // `makeArtifactsFileOps` folds `..` lexically only, so a symlink inside the root still reaches out.
1
+ // Scoped FileOps: a lexical check on the plugin-supplied path, then a realpath check so a
2
+ // symlink inside the scope root cannot lead out of it. Check-then-use, like `resolveWithinRoot`: not atomic
3
+ // against a path swapped between the check and the call (Node fs has no openat / no-follow-parent).
3
4
 
4
5
  import path from "node:path";
5
6
  import { mkdir, readFile, readdir, realpath, stat as fsStat, unlink as fsUnlink } from "node:fs/promises";
6
7
  import type { FileOps } from "gui-chat-protocol";
7
8
  import { writeFileAtomic } from "../utils/files/atomic.js";
8
- import { resolveWithinRoot, resolveWriteWithinRoot } from "../utils/files/safe.js";
9
- import { workspacePath } from "../workspace/paths.js";
9
+ import { isEnoent, resolveWriteWithinRoot } from "../utils/files/safe.js";
10
+ import { ensureInsideBase } from "./runtime-loader.js";
11
+
12
+ // ─────────────────────────────────────────────────────────────────────
13
+ // Path normalisation contract (see plans/done/feat-plugin-runtime-extensions-1110.md)
14
+ //
15
+ // 1. Replace `\` with `/` (Windows path.join leak repair).
16
+ // 2. `path.posix.normalize` (folds `..`, `.`, `//`).
17
+ // 3. Reject if the normalised form starts with `..` or `/`
18
+ // (would escape the scope root or be absolute).
19
+ // 4. `path.join(scopeRoot, ...segments)` — platform-aware join so
20
+ // the returned absolute path uses the host OS separator
21
+ // (backslashes on Windows, forward slashes on POSIX). Critical
22
+ // for `ensureInsideBase` to accept the result.
23
+ //
24
+ // Plugin authors should never need `node:path`; the platform meets
25
+ // them where they are even if they mis-import it on Windows.
26
+ // ─────────────────────────────────────────────────────────────────────
27
+
28
+ /** Resolve a plugin-supplied path to an absolute path inside the
29
+ * plugin's scope root. Throws on traversal. Lexical only — symlinks
30
+ * are checked by the FileOps below. Exported for tests. */
31
+ export function normalizePluginPath(scopeRoot: string, rel: string): string {
32
+ const slashed = rel.replace(/\\/g, "/");
33
+ const normalised = path.posix.normalize(slashed);
34
+ // `path.posix.normalize` produces `..` alone if the input is `..` itself, hence the equality branch.
35
+ if (normalised === ".." || normalised.startsWith("../") || normalised.startsWith("/")) {
36
+ throw new Error(`path escapes plugin scope: ${rel}`);
37
+ }
38
+ const segments = normalised === "." ? [] : normalised.split("/");
39
+ const absolute = path.join(scopeRoot, ...segments);
40
+ // Defence in depth so a future change to the lexical step cannot silently regress.
41
+ if (!ensureInsideBase(absolute, scopeRoot)) {
42
+ throw new Error(`path escapes plugin scope: ${rel}`);
43
+ }
44
+ return absolute;
45
+ }
46
+
47
+ /** The lexically checked path, relative to the scope root in host-native form ("" for the root). */
48
+ const scopeRelative = (scopeRoot: string, rel: string): string => path.relative(scopeRoot, normalizePluginPath(scopeRoot, rel));
10
49
 
11
50
  /** An escape reads as a missing file, so callers that treat ENOENT as "not there" stay closed. */
12
51
  const notInScope = (rel: string): Error & { code: string } => Object.assign(new Error(`not inside the scope: ${rel}`), { code: "ENOENT" });
13
52
 
53
+ /** Only a missing path reads as null; EACCES, ELOOP, ENOTDIR … propagate as before. */
54
+ const realpathIfPresent = async (absPath: string): Promise<string | null> => {
55
+ try {
56
+ return await realpath(absPath);
57
+ } catch (err) {
58
+ if (isEnoent(err)) return null;
59
+ throw err;
60
+ }
61
+ };
62
+
63
+ const isWithin = (rootReal: string, candidateReal: string): boolean => candidateReal === rootReal || candidateReal.startsWith(rootReal + path.sep);
64
+
65
+ /** Real location of `relative` (host-native, from `scopeRelative`) if it exists inside the root; null when missing or outside. */
66
+ const resolveExisting = async (rootReal: string, relative: string): Promise<string | null> => {
67
+ const candidateReal = await realpathIfPresent(path.resolve(rootReal, relative));
68
+ return candidateReal !== null && isWithin(rootReal, candidateReal) ? candidateReal : null;
69
+ };
70
+
71
+ // Only a write creates the scope root; a read-only call against an absent root sees "missing".
14
72
  const realRoot = async (scopeRoot: string): Promise<string> => {
15
73
  await mkdir(scopeRoot, { recursive: true });
16
74
  return realpath(scopeRoot);
17
75
  };
18
76
 
19
- const existingPath = async (scopeRoot: string, rel: string): Promise<string | null> => resolveWithinRoot(await realRoot(scopeRoot), rel);
77
+ const existingPath = async (scopeRoot: string, rel: string): Promise<string | null> => {
78
+ const relative = scopeRelative(scopeRoot, rel);
79
+ const rootReal = await realpathIfPresent(scopeRoot);
80
+ return rootReal === null ? null : resolveExisting(rootReal, relative);
81
+ };
20
82
 
21
83
  const requireExisting = async (scopeRoot: string, rel: string): Promise<string> => {
22
84
  const resolved = await existingPath(scopeRoot, rel);
@@ -24,6 +86,31 @@ const requireExisting = async (scopeRoot: string, rel: string): Promise<string>
24
86
  return resolved;
25
87
  };
26
88
 
89
+ const writeTarget = async (scopeRoot: string, rel: string): Promise<string> => {
90
+ const target = await resolveWriteWithinRoot(await realRoot(scopeRoot), scopeRelative(scopeRoot, rel));
91
+ if (target === null) throw new Error(`refusing to write outside the scope: ${rel}`);
92
+ return target;
93
+ };
94
+
95
+ // unlink does not follow the leaf, so only the parent is confined: a link is removed, never its target.
96
+ const unlinkTarget = async (scopeRoot: string, rel: string): Promise<string | null> => {
97
+ const relative = scopeRelative(scopeRoot, rel);
98
+ const rootReal = await realpathIfPresent(scopeRoot);
99
+ if (rootReal === null) return null;
100
+ if (relative === "") return rootReal;
101
+ const parentReal = await resolveExisting(rootReal, path.dirname(relative));
102
+ return parentReal === null ? null : path.join(parentReal, path.basename(relative));
103
+ };
104
+
105
+ const ignoreEnoent = async <T>(operation: () => Promise<T>, fallback: T): Promise<T> => {
106
+ try {
107
+ return await operation();
108
+ } catch (err) {
109
+ if (isEnoent(err)) return fallback;
110
+ throw err;
111
+ }
112
+ };
113
+
27
114
  export function makeConfinedFileOps(scopeRoot: string): FileOps {
28
115
  return {
29
116
  read: async (rel) => readFile(await requireExisting(scopeRoot, rel), "utf-8"),
@@ -31,14 +118,10 @@ export function makeConfinedFileOps(scopeRoot: string): FileOps {
31
118
  const buf = await readFile(await requireExisting(scopeRoot, rel));
32
119
  return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
33
120
  },
34
- write: async (rel, content) => {
35
- const target = await resolveWriteWithinRoot(await realRoot(scopeRoot), rel);
36
- if (target === null) throw new Error(`refusing to write outside the scope: ${rel}`);
37
- await writeFileAtomic(target, content);
38
- },
121
+ write: async (rel, content) => writeFileAtomic(await writeTarget(scopeRoot, rel), content),
39
122
  readDir: async (rel) => {
40
123
  const resolved = await existingPath(scopeRoot, rel);
41
- return resolved === null ? [] : readdir(resolved);
124
+ return resolved === null ? [] : ignoreEnoent(() => readdir(resolved), []);
42
125
  },
43
126
  stat: async (rel) => {
44
127
  const { mtimeMs, size } = await fsStat(await requireExisting(scopeRoot, rel));
@@ -46,11 +129,8 @@ export function makeConfinedFileOps(scopeRoot: string): FileOps {
46
129
  },
47
130
  exists: async (rel) => (await existingPath(scopeRoot, rel)) !== null,
48
131
  unlink: async (rel) => {
49
- const resolved = await existingPath(scopeRoot, rel);
50
- if (resolved !== null) await fsUnlink(resolved);
132
+ const target = await unlinkTarget(scopeRoot, rel);
133
+ if (target !== null) await ignoreEnoent(() => fsUnlink(target), undefined);
51
134
  },
52
135
  };
53
136
  }
54
-
55
- /** The workspace `artifacts/` dir, confined as above. */
56
- export const makeConfinedArtifactsFileOps = (): FileOps => makeConfinedFileOps(path.join(workspacePath, "artifacts"));
@@ -3,25 +3,23 @@
3
3
  //
4
4
  // Every helper closes over `pkgName` so a plugin's pubsub channel,
5
5
  // data dir, log prefix etc. cannot leak across plugins. Path arguments
6
- // to `files.{data,config}` are normalised to POSIX, then anchored
7
- // inside the plugin's scope root via `ensureInsideBase` — so misuse of
8
- // `node:path` on Windows still works and `"../../etc/passwd"` is
9
- // rejected.
6
+ // to `files.{data,config,artifacts}` are confined to their scope root
7
+ // by `makeConfinedFileOps` — lexically, then by realpath, so neither
8
+ // `"../../etc/passwd"` nor a symlink inside the root leads out.
10
9
  //
11
10
  // This module is server-side only. The browser-side counterpart lives
12
11
  // at `src/utils/plugin/runtime.ts`.
13
12
 
14
13
  import path from "node:path";
15
- import { readFile, readdir, stat as fsStat, unlink as fsUnlink } from "node:fs/promises";
16
14
  import { randomUUID } from "node:crypto";
17
15
  import type { FileOps, PluginFetchJsonOptions, PluginFetchOptions, PluginRuntime } from "gui-chat-protocol";
18
16
 
19
17
  import { WORKSPACE_PATHS, workspacePath } from "../workspace/paths.js";
20
18
  import { writeFileAtomic } from "../utils/files/atomic.js";
21
19
  import { errorMessage } from "../utils/errors.js";
22
- import { isErrorWithCode, isRecord } from "../utils/types.js";
20
+ import { isRecord } from "../utils/types.js";
23
21
  import { log as hostLog, type Logger } from "../system/logger/index.js";
24
- import { ensureInsideBase } from "./runtime-loader.js";
22
+ import { makeConfinedFileOps } from "./confined-file-ops.js";
25
23
  import { ONE_SECOND_MS } from "../utils/time.js";
26
24
  import type { IPubSub } from "../events/pub-sub/index.js";
27
25
  import * as notifierEngine from "../notifier/engine.js";
@@ -35,59 +33,6 @@ import { BUILTIN_ROLE_IDS } from "../../src/config/roles.js";
35
33
 
36
34
  const DEFAULT_FETCH_TIMEOUT_MS = 10 * ONE_SECOND_MS;
37
35
 
38
- // ─────────────────────────────────────────────────────────────────────
39
- // Path normalisation contract (see plans/done/feat-plugin-runtime-extensions-1110.md)
40
- //
41
- // 1. Replace `\` with `/` (Windows path.join leak repair).
42
- // 2. `path.posix.normalize` (folds `..`, `.`, `//`).
43
- // 3. Reject if the normalised form starts with `..` or `/`
44
- // (would escape the scope root or be absolute).
45
- // 4. `path.join(scopeRoot, ...segments)` — platform-aware join so
46
- // the returned absolute path uses the host OS separator
47
- // (backslashes on Windows, forward slashes on POSIX). Critical
48
- // for `ensureInsideBase` to accept the result.
49
- //
50
- // Plugin authors should never need `node:path`; the platform meets
51
- // them where they are even if they mis-import it on Windows.
52
- // ─────────────────────────────────────────────────────────────────────
53
-
54
- /** Resolve a plugin-supplied path to an absolute path inside the
55
- * plugin's scope root. Throws on traversal. Exported for tests.
56
- *
57
- * Cross-platform: `scopeRoot` is the host OS's native form (drive
58
- * letter + backslashes on Windows, forward slashes on POSIX). The
59
- * user-supplied `rel` is POSIX-relative by contract; we sanitise
60
- * for the lexical traversal check via `path.posix.*`, then join
61
- * with `path.join` so the returned absolute path is in the host's
62
- * native form (which `ensureInsideBase` requires). */
63
- export function normalizePluginPath(scopeRoot: string, rel: string): string {
64
- // Defang Windows path.join output and any mixed-separator inputs.
65
- const slashed = rel.replace(/\\/g, "/");
66
- // Fold `..` / `.` / repeated `/` lexically (no fs touch yet).
67
- const normalised = path.posix.normalize(slashed);
68
- // Reject lexically — anything that escapes the scope root after
69
- // normalisation either starts with `..` (parent-traversal) or is
70
- // absolute (`/etc/passwd`). `path.posix.normalize` produces `..`
71
- // alone if the input is `..` itself, hence the equality branch.
72
- if (normalised === ".." || normalised.startsWith("../") || normalised.startsWith("/")) {
73
- throw new Error(`path escapes plugin scope: ${rel}`);
74
- }
75
- const segments = normalised === "." ? [] : normalised.split("/");
76
- // `path.join` (platform-aware) so the result uses the host
77
- // separator. On Windows that's backslashes; on POSIX, forward
78
- // slashes — matching `scopeRoot`'s existing form so
79
- // `ensureInsideBase`'s `path.resolve` + `path.sep` comparison
80
- // works across both platforms.
81
- const absolute = path.join(scopeRoot, ...segments);
82
- // Defence in depth: even though the lexical check above should
83
- // catch every escape, run `ensureInsideBase` so a future change to
84
- // either step doesn't silently regress the safety guarantee.
85
- if (!ensureInsideBase(absolute, scopeRoot)) {
86
- throw new Error(`path escapes plugin scope: ${rel}`);
87
- }
88
- return absolute;
89
- }
90
-
91
36
  // ─────────────────────────────────────────────────────────────────────
92
37
  // Scoped FileOps factory
93
38
  // ─────────────────────────────────────────────────────────────────────
@@ -99,59 +44,7 @@ export function normalizePluginPath(scopeRoot: string, rel: string): string {
99
44
  * category subdir (`charts/`, …) and all write logic; the host only
100
45
  * provides this generic primitive. */
101
46
  export function makeArtifactsFileOps(): FileOps {
102
- return makeFileOps(path.join(workspacePath, "artifacts"));
103
- }
104
-
105
- function makeFileOps(scopeRoot: string): FileOps {
106
- // The scope root may not exist on first use. Lazy-create at write
107
- // time (writeFileAtomic does its own mkdir for the file's parent;
108
- // we ensure the scope root exists for readDir() / stat() etc.).
109
- // Reads against a never-written plugin throw ENOENT — exposed as
110
- // `exists() === false` rather than handled here.
111
-
112
- return {
113
- async read(rel) {
114
- return readFile(normalizePluginPath(scopeRoot, rel), "utf-8");
115
- },
116
- async readBytes(rel) {
117
- const buf = await readFile(normalizePluginPath(scopeRoot, rel));
118
- return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
119
- },
120
- async write(rel, content) {
121
- const abs = normalizePluginPath(scopeRoot, rel);
122
- await writeFileAtomic(abs, content);
123
- },
124
- async readDir(rel) {
125
- const abs = normalizePluginPath(scopeRoot, rel);
126
- try {
127
- return await readdir(abs);
128
- } catch (err) {
129
- if (isErrorWithCode(err) && err.code === "ENOENT") return [];
130
- throw err;
131
- }
132
- },
133
- async stat(rel) {
134
- const { mtimeMs, size } = await fsStat(normalizePluginPath(scopeRoot, rel));
135
- return { mtimeMs, size };
136
- },
137
- async exists(rel) {
138
- try {
139
- await fsStat(normalizePluginPath(scopeRoot, rel));
140
- return true;
141
- } catch (err) {
142
- if (isErrorWithCode(err) && err.code === "ENOENT") return false;
143
- throw err;
144
- }
145
- },
146
- async unlink(rel) {
147
- try {
148
- await fsUnlink(normalizePluginPath(scopeRoot, rel));
149
- } catch (err) {
150
- if (isErrorWithCode(err) && err.code === "ENOENT") return;
151
- throw err;
152
- }
153
- },
154
- };
47
+ return makeConfinedFileOps(path.join(workspacePath, "artifacts"));
155
48
  }
156
49
 
157
50
  // ─────────────────────────────────────────────────────────────────────
@@ -395,8 +288,8 @@ export function makePluginRuntime(deps: MakePluginRuntimeDeps): MulmoclaudeRunti
395
288
  pubsub: makeScopedPubSub(pkgName, pubsub),
396
289
  locale,
397
290
  files: {
398
- data: makeFileOps(dataRoot),
399
- config: makeFileOps(configRoot),
291
+ data: makeConfinedFileOps(dataRoot),
292
+ config: makeConfinedFileOps(configRoot),
400
293
  artifacts: makeArtifactsFileOps(),
401
294
  },
402
295
  log: makeScopedLogger(pkgName),
@@ -6,7 +6,7 @@
6
6
  // after a save that wrote the file. Imported for side effect at boot (server/index.ts).
7
7
 
8
8
  import { executeSpreadsheetDispatch, isSpreadsheetDispatchArgs } from "@gui-chat-plugin/spreadsheet";
9
- import { makeConfinedArtifactsFileOps } from "./confined-file-ops.js";
9
+ import { makeArtifactsFileOps } from "./runtime.js";
10
10
  import { publishFileChange } from "../events/file-change.js";
11
11
  import { describeKind, registerBuiltinDispatch } from "./builtin-dispatch.js";
12
12
  import { wroteSpreadsheetFile } from "./spreadsheet-save-outcome.js";
@@ -18,7 +18,7 @@ registerBuiltinDispatch(SPREADSHEET_SCOPE, async (args) => {
18
18
  if (!isSpreadsheetDispatchArgs(args)) {
19
19
  throw new Error(`spreadsheet plugin: unrecognised dispatch payload (kind=${describeKind(args)})`);
20
20
  }
21
- const result = await executeSpreadsheetDispatch(makeConfinedArtifactsFileOps(), args);
21
+ const result = await executeSpreadsheetDispatch(makeArtifactsFileOps(), args);
22
22
  if (wroteSpreadsheetFile(args, result.data)) {
23
23
  void publishFileChange(args.path);
24
24
  }