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
|
@@ -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:
|
|
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
|
|
2
|
-
//
|
|
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 {
|
|
9
|
-
import {
|
|
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> =>
|
|
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
|
|
50
|
-
if (
|
|
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
|
|
7
|
-
//
|
|
8
|
-
// `
|
|
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 {
|
|
20
|
+
import { isRecord } from "../utils/types.js";
|
|
23
21
|
import { log as hostLog, type Logger } from "../system/logger/index.js";
|
|
24
|
-
import {
|
|
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
|
|
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:
|
|
399
|
-
config:
|
|
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 {
|
|
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(
|
|
21
|
+
const result = await executeSpreadsheetDispatch(makeArtifactsFileOps(), args);
|
|
22
22
|
if (wroteSpreadsheetFile(args, result.data)) {
|
|
23
23
|
void publishFileChange(args.path);
|
|
24
24
|
}
|