@code-yeongyu/senpi-codemode 2026.10.27-2 → 2026.10.30-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/CHANGELOG.md +49 -0
- package/package.json +4 -4
- package/src/bridge/http-server.ts +18 -5
- package/src/bridge/permission-check.ts +76 -0
- package/src/bridge/protocol.ts +7 -0
- package/src/kernels/jl/kernel.ts +3 -0
- package/src/kernels/js/context-manager.ts +26 -8
- package/src/kernels/js/group-signal-notice.js +277 -1
- package/src/kernels/js/inline-worker-entry.js +1 -23
- package/src/kernels/js/inline-worker.ts +7 -2
- package/src/kernels/js/interpose-bun.js +220 -0
- package/src/kernels/js/interpose-capabilities.js +29 -0
- package/src/kernels/js/interpose-code.js +8 -0
- package/src/kernels/js/interpose-fs.js +124 -0
- package/src/kernels/js/interpose-modules.js +107 -0
- package/src/kernels/js/interpose-paths.js +80 -0
- package/src/kernels/js/interpose-process.js +97 -0
- package/src/kernels/js/interpose-resources.js +59 -0
- package/src/kernels/js/interpose-sqlite.js +104 -0
- package/src/kernels/js/interpose.js +86 -0
- package/src/kernels/js/kernel-contract.ts +31 -0
- package/src/kernels/js/kernel-tools-pump.js +4 -2
- package/src/kernels/js/local-module-loader.ts +9 -4
- package/src/kernels/js/permission-authority.d.mts +10 -0
- package/src/kernels/js/permission-authority.mjs +11 -0
- package/src/kernels/js/permission-core.md +114 -0
- package/src/kernels/js/permission-host.ts +388 -0
- package/src/kernels/js/permission-runtime.js +205 -0
- package/src/kernels/js/permission-sessions.ts +46 -0
- package/src/kernels/js/permission-slots.d.mts +51 -0
- package/src/kernels/js/permission-slots.mjs +133 -0
- package/src/kernels/js/permission-worker-entry.js +1 -0
- package/src/kernels/js/permission-worker-frames.ts +55 -0
- package/src/kernels/js/process-tree.js +9 -1
- package/src/kernels/js/rewrite-imports.ts +44 -0
- package/src/kernels/js/windows-tree-kill.js +10 -2
- package/src/kernels/js/worker-core.d.ts +2 -0
- package/src/kernels/js/worker-core.js +5 -2
- package/src/kernels/js/worker-cwd.js +14 -8
- package/src/kernels/js/worker-entry.js +1 -23
- package/src/kernels/js/worker-file-helpers.js +58 -0
- package/src/kernels/js/worker-host.ts +24 -6
- package/src/kernels/js/worker-package-resolve.js +10 -2
- package/src/kernels/js/worker-runtime.js +5 -65
- package/src/kernels/js/worker-slot.ts +5 -1
- package/src/kernels/js/worker-startup.ts +7 -5
- package/src/kernels/js/worker-webview.js +17 -16
- package/src/kernels/py/interpose.py +765 -0
- package/src/kernels/py/kernel-contract.ts +4 -0
- package/src/kernels/py/kernel-tools-host.ts +5 -1
- package/src/kernels/py/kernel.ts +45 -5
- package/src/kernels/py/path_identity.py +162 -0
- package/src/kernels/py/path_identity_posix.py +74 -0
- package/src/kernels/py/path_identity_windows.py +93 -0
- package/src/kernels/py/permission-core.md +103 -0
- package/src/kernels/py/permission-host.ts +198 -0
- package/src/kernels/py/permission_bridge.py +145 -0
- package/src/kernels/py/prelude.py +40 -5
- package/src/kernels/py/transport.ts +15 -8
- package/src/kernels/rb/kernel.ts +3 -0
- package/src/kernels/shared/invocation-authority.ts +59 -0
- package/src/kernels/shared/kernel-tools-pump.ts +22 -1
- package/src/kernels/shared/path-identity.d.mts +42 -0
- package/src/kernels/shared/path-identity.mjs +149 -0
- package/src/kernels/shared/subprocess-contract.ts +2 -0
- package/src/kernels/shared/subprocess-kernel.ts +28 -0
|
@@ -1,23 +1 @@
|
|
|
1
|
-
import
|
|
2
|
-
import { createWorkerCore } from "./worker-core.js";
|
|
3
|
-
|
|
4
|
-
if (!parentPort) throw new Error("JS kernel inline fallback worker missing parentPort");
|
|
5
|
-
|
|
6
|
-
const transport = {
|
|
7
|
-
send(message) {
|
|
8
|
-
parentPort.postMessage(message);
|
|
9
|
-
},
|
|
10
|
-
onMessage(handler) {
|
|
11
|
-
parentPort.on("message", handler);
|
|
12
|
-
return () => parentPort.off("message", handler);
|
|
13
|
-
},
|
|
14
|
-
close() {
|
|
15
|
-
parentPort.close();
|
|
16
|
-
setTimeout(() => process.exit(0), 0);
|
|
17
|
-
},
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
createWorkerCore(transport, {
|
|
21
|
-
cwd: workerData.cwd,
|
|
22
|
-
parallelPoolWidth: workerData.parallelPoolWidth,
|
|
23
|
-
});
|
|
1
|
+
import "./interpose.js";
|
|
@@ -4,6 +4,7 @@ import type { MessagePort } from "node:worker_threads";
|
|
|
4
4
|
import type { HostToKernelMessage, KernelToHostMessage } from "../../bridge/protocol.ts";
|
|
5
5
|
import { type CodemodeRuntimeAssetEnvironment, requireCodemodeRuntimeAsset } from "../shared/runtime-asset.ts";
|
|
6
6
|
import type { JavaScriptKernelMode } from "./kernel-contract.ts";
|
|
7
|
+
import type { KernelPermissionHost } from "./permission-host.ts";
|
|
7
8
|
import { spawnNodeWorker } from "./worker-host.ts";
|
|
8
9
|
|
|
9
10
|
export interface JavaScriptInlineWorkerEntryUrlOptions extends CodemodeRuntimeAssetEnvironment {
|
|
@@ -27,6 +28,10 @@ export interface WorkerLike {
|
|
|
27
28
|
terminate(): Promise<void>;
|
|
28
29
|
}
|
|
29
30
|
|
|
30
|
-
export function createInlineWorker(
|
|
31
|
-
|
|
31
|
+
export function createInlineWorker(
|
|
32
|
+
cwd: string,
|
|
33
|
+
parallelPoolWidth: number,
|
|
34
|
+
permissions?: KernelPermissionHost,
|
|
35
|
+
): WorkerLike {
|
|
36
|
+
return spawnNodeWorker(resolveInlineWorkerEntryUrl(), cwd, parallelPoolWidth, "inline", permissions);
|
|
32
37
|
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { checkPath, executionPath, replaceMethod } from "./interpose-paths.js";
|
|
2
|
+
|
|
3
|
+
const READERS = ["text", "json", "bytes", "arrayBuffer", "stream", "formData", "image"];
|
|
4
|
+
const ignore = () => {};
|
|
5
|
+
const WRITERS = ["write", "delete", "unlink"];
|
|
6
|
+
|
|
7
|
+
export function installBunPermissions(check, resources) {
|
|
8
|
+
const bun = globalThis.Bun;
|
|
9
|
+
const stand = new WeakMap();
|
|
10
|
+
const unwrap = (value) => stand.get(value) ?? value;
|
|
11
|
+
const files = { source: () => {}, destination: () => {}, handle: () => false };
|
|
12
|
+
if (typeof bun?.file === "function") {
|
|
13
|
+
const originalFile = bun.file;
|
|
14
|
+
const proto = Object.getPrototypeOf(originalFile(new URL(import.meta.url)));
|
|
15
|
+
// Captured before any cell runs, so a per-instance "name" cannot redirect a decision away
|
|
16
|
+
// from the path the runtime will actually open.
|
|
17
|
+
const nativePath = Object.getOwnPropertyDescriptor(proto, "name").get;
|
|
18
|
+
const nativeStat = proto.stat;
|
|
19
|
+
// A memory Blob carries no file and answers nothing; anything the native stat does answer for is
|
|
20
|
+
// backed by a file this decision cannot name, which is what makes it undecidable rather than safe.
|
|
21
|
+
const fileBacked = (value) => {
|
|
22
|
+
let pending;
|
|
23
|
+
try {
|
|
24
|
+
pending = nativeStat.call(value);
|
|
25
|
+
} catch {
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
if (pending === undefined) return false;
|
|
29
|
+
if (typeof pending?.then === "function") pending.then(ignore, ignore);
|
|
30
|
+
return true;
|
|
31
|
+
};
|
|
32
|
+
const pathOf = (value) => {
|
|
33
|
+
try {
|
|
34
|
+
return nativePath.call(unwrap(value));
|
|
35
|
+
} catch {
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
const granted = (value, mode) => {
|
|
40
|
+
const grant = resources.path(value);
|
|
41
|
+
if (grant === undefined) return false;
|
|
42
|
+
resources.use(typeof grant === "number" ? grant : value, mode);
|
|
43
|
+
return true;
|
|
44
|
+
};
|
|
45
|
+
const handle = (value, mode) => {
|
|
46
|
+
if (typeof value === "number") return granted(value, mode);
|
|
47
|
+
if (value === null || typeof value !== "object") return false;
|
|
48
|
+
const path = pathOf(value);
|
|
49
|
+
if (typeof path === "string") {
|
|
50
|
+
checkPath(check, mode, path);
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
if (granted(unwrap(value), mode)) return true;
|
|
54
|
+
if (!(unwrap(value) instanceof Blob)) return false;
|
|
55
|
+
// A file-backed handle whose path cannot be read cannot be decided, so it never receives bytes
|
|
56
|
+
// and never hands any out either.
|
|
57
|
+
if (mode === "write") check(() => ({ unsupported: true }));
|
|
58
|
+
if (mode === "read") check(() => (fileBacked(unwrap(value)) ? { unsupported: true } : { granted: true })); // sink:undecidable-read
|
|
59
|
+
return true;
|
|
60
|
+
};
|
|
61
|
+
const source = (value) => {
|
|
62
|
+
for (const part of Array.isArray(value) ? value : [value]) handle(part, "read");
|
|
63
|
+
};
|
|
64
|
+
const destination = (value) => {
|
|
65
|
+
if (typeof value === "number") resources.use(value, "write");
|
|
66
|
+
else if (!handle(value, "write")) checkPath(check, "write", value);
|
|
67
|
+
};
|
|
68
|
+
const native = (value) => (Array.isArray(value) ? value.map(unwrap) : unwrap(value));
|
|
69
|
+
Object.assign(files, { source, destination, handle });
|
|
70
|
+
const standIn = (file) => {
|
|
71
|
+
// Metadata answers for the same file the content was refused for, so it is decided the same way.
|
|
72
|
+
const metadata = () => checkPath(check, "read", nativePath.call(file));
|
|
73
|
+
const surface = {
|
|
74
|
+
exists: (...args) => {
|
|
75
|
+
metadata(); // sink:standin-metadata
|
|
76
|
+
return proto.exists.apply(file, args);
|
|
77
|
+
},
|
|
78
|
+
stat: (...args) => {
|
|
79
|
+
metadata(); // sink:standin-metadata
|
|
80
|
+
return nativeStat.apply(file, args);
|
|
81
|
+
},
|
|
82
|
+
slice: (...args) => standIn(proto.slice.apply(file, args)),
|
|
83
|
+
};
|
|
84
|
+
// The native accessors live on the prototype, so these stay off the own enumerable surface too:
|
|
85
|
+
// a structured-clone walk still fails on the missing internal slots rather than on a check.
|
|
86
|
+
Object.defineProperties(surface, {
|
|
87
|
+
name: { get: () => nativePath.call(file), configurable: true },
|
|
88
|
+
type: { get: () => file.type, configurable: true },
|
|
89
|
+
size: {
|
|
90
|
+
get: () => {
|
|
91
|
+
metadata(); // sink:standin-metadata
|
|
92
|
+
return file.size;
|
|
93
|
+
},
|
|
94
|
+
configurable: true,
|
|
95
|
+
},
|
|
96
|
+
lastModified: {
|
|
97
|
+
get: () => {
|
|
98
|
+
metadata(); // sink:standin-metadata
|
|
99
|
+
return file.lastModified;
|
|
100
|
+
},
|
|
101
|
+
configurable: true,
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
for (const name of [...READERS, ...WRITERS, "writer"]) {
|
|
105
|
+
surface[name] = (...args) => proto[name].apply(file, args);
|
|
106
|
+
}
|
|
107
|
+
stand.set(surface, file);
|
|
108
|
+
return surface;
|
|
109
|
+
};
|
|
110
|
+
for (const name of READERS) {
|
|
111
|
+
replaceMethod(proto, name, (original) => function (...args) {
|
|
112
|
+
source(this); // sink:bunfile-read
|
|
113
|
+
return original.apply(unwrap(this), args);
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
for (const name of WRITERS) {
|
|
117
|
+
replaceMethod(proto, name, (original) => function (...args) {
|
|
118
|
+
destination(this); // sink:bunfile-write
|
|
119
|
+
if (name === "write") source(args[0]); // sink:bunfile-write-parts
|
|
120
|
+
return original.apply(unwrap(this), name === "write" ? [native(args[0]), ...args.slice(1)] : args);
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
replaceMethod(proto, "writer", (original) => function (...args) {
|
|
124
|
+
destination(this); // sink:bunfile-writer
|
|
125
|
+
const writer = original.apply(unwrap(this), args);
|
|
126
|
+
resources.record(writer, pathOf(this), ["write"]);
|
|
127
|
+
for (const name of ["write", "flush", "end"]) {
|
|
128
|
+
replaceMethod(writer, name, (fn) => function (...parameters) {
|
|
129
|
+
resources.use(this, "write");
|
|
130
|
+
return fn.apply(this, parameters);
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
return writer;
|
|
134
|
+
});
|
|
135
|
+
bun.file = function (path, ...args) {
|
|
136
|
+
const file = originalFile.call(this, executionPath(path), ...args);
|
|
137
|
+
if (typeof path === "number") return resources.record(file, path, ["read", "write"]);
|
|
138
|
+
try {
|
|
139
|
+
checkPath(check, "read", path); // sink:bun-file-mint
|
|
140
|
+
} catch (error) {
|
|
141
|
+
if (error?.code !== "cell_permission_denied") throw error;
|
|
142
|
+
// A denied path yields a stand-in with no internal slots, so every native consumer
|
|
143
|
+
// of a file handle refuses it instead of reading the file behind our back.
|
|
144
|
+
return standIn(file);
|
|
145
|
+
}
|
|
146
|
+
return file;
|
|
147
|
+
};
|
|
148
|
+
replaceMethod(bun, "write", (original) => function (target, data, ...args) {
|
|
149
|
+
destination(target); // sink:bun-write-destination
|
|
150
|
+
source(data); // sink:bun-write-parts
|
|
151
|
+
return original.call(this, executionPath(unwrap(target)), native(data), ...args);
|
|
152
|
+
});
|
|
153
|
+
// A route value may be a per-method map ({'/f':{GET: handle}}), which Bun reads the handle out of
|
|
154
|
+
// exactly like the flat form, so every nested value is decided too.
|
|
155
|
+
const nestedRoutes = (value, depth, seen) => {
|
|
156
|
+
if (value === null || typeof value !== "object" || depth === 0 || seen.has(value)) return;
|
|
157
|
+
if (unwrap(value) instanceof Blob) return;
|
|
158
|
+
seen.add(value);
|
|
159
|
+
for (const inner of Object.values(value)) {
|
|
160
|
+
source(inner);
|
|
161
|
+
nestedRoutes(inner, depth - 1, seen);
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
replaceMethod(bun, "serve", (original) => function (options, ...args) {
|
|
165
|
+
for (const map of [options?.routes, options?.static]) {
|
|
166
|
+
for (const value of map === null || typeof map !== "object" ? [] : Object.values(map)) {
|
|
167
|
+
source(value); // sink:bun-serve
|
|
168
|
+
nestedRoutes(value, 3, new Set()); // sink:bun-serve-nested
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return original.call(this, options, ...args);
|
|
172
|
+
});
|
|
173
|
+
const buffer = process.getBuiltinModule("node:buffer");
|
|
174
|
+
for (const name of ["Blob", "File", "Response", "Request"]) {
|
|
175
|
+
const original = globalThis[name];
|
|
176
|
+
const guarded = new Proxy(original, {
|
|
177
|
+
construct(target, args, newTarget) {
|
|
178
|
+
if (name === "Blob" || name === "File") source(args[0]);
|
|
179
|
+
else source(name === "Response" ? args[0] : args[1]?.body);
|
|
180
|
+
return Reflect.construct(target, args, newTarget);
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
globalThis[name] = guarded;
|
|
184
|
+
if (buffer[name] === original) buffer[name] = guarded;
|
|
185
|
+
if (original.prototype.constructor === original) {
|
|
186
|
+
Object.defineProperty(original.prototype, "constructor", { value: guarded });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
for (const name of ["append", "set"]) {
|
|
190
|
+
replaceMethod(FormData.prototype, name, (original) => function (key, value, ...args) {
|
|
191
|
+
source(value);
|
|
192
|
+
return original.call(this, key, value, ...args);
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (bun?.Glob) {
|
|
197
|
+
for (const name of ["scan", "scanSync"]) {
|
|
198
|
+
replaceMethod(bun.Glob.prototype, name, (original) => function (options) {
|
|
199
|
+
const cwd = typeof options === "string" ? options : (options?.cwd ?? process.cwd());
|
|
200
|
+
checkPath(check, "ls", cwd);
|
|
201
|
+
return original.call(
|
|
202
|
+
this,
|
|
203
|
+
typeof options === "string" ? executionPath(cwd) : { ...options, cwd: executionPath(cwd) },
|
|
204
|
+
);
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
replaceMethod(bun, "mmap", (original) => function (path, options) {
|
|
209
|
+
check(() => ({ granted: true, unsupported: options?.shared !== false }));
|
|
210
|
+
checkPath(check, "read", path);
|
|
211
|
+
return original.call(this, executionPath(path), options);
|
|
212
|
+
});
|
|
213
|
+
replaceMethod(globalThis, "fetch", (original) => function (input, ...args) {
|
|
214
|
+
const href = input instanceof Request ? input.url : String(input);
|
|
215
|
+
if (href.startsWith("file:")) checkPath(check, "read", new URL(href));
|
|
216
|
+
files.source(args[0]?.body);
|
|
217
|
+
return original.call(this, input, ...args);
|
|
218
|
+
});
|
|
219
|
+
return files;
|
|
220
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { replaceMethod } from "./interpose-paths.js";
|
|
2
|
+
|
|
3
|
+
export function installCapabilityPermissions(check) {
|
|
4
|
+
const refuse = () => check(() => ({ unsupported: true }));
|
|
5
|
+
for (const name of ["binding", "_linkedBinding", "dlopen"]) {
|
|
6
|
+
replaceMethod(process, name, (original) => function (...args) {
|
|
7
|
+
refuse();
|
|
8
|
+
return original.apply(this, args);
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
if (globalThis.Bun) {
|
|
12
|
+
const original = globalThis.Bun.plugin;
|
|
13
|
+
Object.defineProperty(globalThis, Symbol.for("senpi.kernel.permissionBunPlugin"), { value: function (...args) {
|
|
14
|
+
refuse();
|
|
15
|
+
return original(...args);
|
|
16
|
+
} });
|
|
17
|
+
}
|
|
18
|
+
const module = process.getBuiltinModule("node:module");
|
|
19
|
+
replaceMethod(module, "register", (original) => function (...args) {
|
|
20
|
+
refuse();
|
|
21
|
+
return original.apply(this, args);
|
|
22
|
+
});
|
|
23
|
+
let load = module._load;
|
|
24
|
+
Object.defineProperty(module, "_load", {
|
|
25
|
+
configurable: true, enumerable: true,
|
|
26
|
+
get: () => load,
|
|
27
|
+
set(value) { refuse(); load = value; },
|
|
28
|
+
});
|
|
29
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export function createCodePermissionGuard(check) {
|
|
2
|
+
const scan = (source) => check(() => {
|
|
3
|
+
const prepared = source?.startsWith("/*senpi:prepared-cell*/")
|
|
4
|
+
? JSON.parse(source.slice("/*senpi:prepared-cell*/".length)) : undefined;
|
|
5
|
+
return { granted: true, unsupported: prepared?.dynamicCodeCapability === true };
|
|
6
|
+
});
|
|
7
|
+
return { scan };
|
|
8
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { checkPath, executionPath, replaceMethod } from "./interpose-paths.js";
|
|
2
|
+
import { createResourceTable } from "./interpose-resources.js";
|
|
3
|
+
|
|
4
|
+
export const FS_PERMISSION_PATHS = new Set([
|
|
5
|
+
"writeFile", "appendFile", "truncate", "mkdir", "mkdtemp", "rm", "rmdir", "unlink",
|
|
6
|
+
"symlink", "chmod", "chown", "lchmod", "lchown", "utimes", "lutimes", "rename", "link", "copyFile", "cp",
|
|
7
|
+
"readFile", "readlink", "realpath", "readdir", "opendir", "open", "openAsBlob",
|
|
8
|
+
"createReadStream", "createWriteStream",
|
|
9
|
+
]);
|
|
10
|
+
|
|
11
|
+
export function installFilesystemPermissions(check) {
|
|
12
|
+
const fs = process.getBuiltinModule("node:fs");
|
|
13
|
+
const resources = createResourceTable(check);
|
|
14
|
+
const promises = [...new Set([fs.promises, process.getBuiltinModule("node:fs/promises")])];
|
|
15
|
+
const decisions = {
|
|
16
|
+
writeFile: [[0, "write"]], appendFile: [[0, "write"]], truncate: [[0, "write"]],
|
|
17
|
+
mkdir: [[0, "write"]], mkdtemp: [[0, "write"]], rm: [[0, "write"]], rmdir: [[0, "write"]],
|
|
18
|
+
unlink: [[0, "write"]], symlink: [[1, "write"]], chmod: [[0, "write"]], chown: [[0, "write"]],
|
|
19
|
+
lchmod: [[0, "write"]], lchown: [[0, "write"]],
|
|
20
|
+
utimes: [[0, "write"]], lutimes: [[0, "write"]], rename: [[0, "write"], [1, "write"]],
|
|
21
|
+
link: [[0, "write"], [1, "write"]], copyFile: [[0, "read"], [1, "write"]], cp: [[0, "read"], [1, "write"]],
|
|
22
|
+
readFile: [[0, "read"]], readlink: [[0, "read"]], realpath: [[0, "read"]],
|
|
23
|
+
readdir: [[0, "ls"]], opendir: [[0, "ls"]], openAsBlob: [[0, "read"]],
|
|
24
|
+
};
|
|
25
|
+
const guard = (original, entries) => function (...args) {
|
|
26
|
+
const actual = [...args];
|
|
27
|
+
for (const [index, tool] of entries) {
|
|
28
|
+
const value = args[index];
|
|
29
|
+
if (typeof value === "number" || (value !== null && typeof value === "object" && typeof value.fd === "number")) {
|
|
30
|
+
resources.use(value, tool === "write" ? "write" : "read");
|
|
31
|
+
} else {
|
|
32
|
+
checkPath(check, tool, value);
|
|
33
|
+
actual[index] = executionPath(value);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return original.apply(this, actual);
|
|
37
|
+
};
|
|
38
|
+
for (const [name, entries] of Object.entries(decisions)) {
|
|
39
|
+
replaceMethod(fs, name, (original) => guard(original, entries));
|
|
40
|
+
replaceMethod(fs, `${name}Sync`, (original) => guard(original, entries));
|
|
41
|
+
for (const owner of promises) replaceMethod(owner, name, (original) => guard(original, entries));
|
|
42
|
+
}
|
|
43
|
+
replaceMethod(fs.realpath, "native", (original) => guard(original, [[0, "read"]]));
|
|
44
|
+
replaceMethod(fs.realpathSync, "native", (original) => guard(original, [[0, "read"]]));
|
|
45
|
+
for (const owner of [fs, ...promises]) {
|
|
46
|
+
for (const name of ["glob", "globSync"]) replaceMethod(owner, name, (original) => function (pattern, ...args) {
|
|
47
|
+
const options = args[0];
|
|
48
|
+
const cwd = options && typeof options === "object" ? options.cwd ?? process.cwd() : process.cwd();
|
|
49
|
+
checkPath(check, "ls", cwd);
|
|
50
|
+
const effective = options && typeof options === "object" ? { ...options, cwd: executionPath(cwd) } : { cwd: process.cwd() };
|
|
51
|
+
return typeof options === "function"
|
|
52
|
+
? original.call(this, pattern, effective, ...args)
|
|
53
|
+
: original.call(this, pattern, effective, ...args.slice(1));
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
replaceMethod(fs, "openSync", (original) => function (file, flags, ...args) {
|
|
57
|
+
const modes = resources.open(file, flags);
|
|
58
|
+
return resources.record(original.call(this, executionPath(file), flags, ...args), file, modes);
|
|
59
|
+
});
|
|
60
|
+
replaceMethod(fs, "open", (original) => function (file, flags, ...args) {
|
|
61
|
+
if (typeof flags === "function") { args = [flags]; flags = undefined; }
|
|
62
|
+
const modes = resources.open(file, flags);
|
|
63
|
+
const callback = args.at(-1);
|
|
64
|
+
if (typeof callback !== "function") return original.call(this, executionPath(file), flags, ...args);
|
|
65
|
+
return original.call(this, executionPath(file), flags, ...args.slice(0, -1), (error, fd) => {
|
|
66
|
+
if (!error) resources.record(fd, file, modes);
|
|
67
|
+
callback(error, fd);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
for (const owner of promises) replaceMethod(owner, "open", (original) => function (file, flags, ...args) {
|
|
71
|
+
const modes = resources.open(file, flags);
|
|
72
|
+
return original.call(this, executionPath(file), flags, ...args).then((handle) => resources.handle(handle, file, modes));
|
|
73
|
+
});
|
|
74
|
+
for (const [mode, names] of [
|
|
75
|
+
["read", ["read", "readv"]],
|
|
76
|
+
["write", ["write", "writev", "ftruncate", "fchmod", "fchown", "futimes", "fsync", "fdatasync"]],
|
|
77
|
+
]) {
|
|
78
|
+
for (const name of names) {
|
|
79
|
+
for (const key of [name, `${name}Sync`]) replaceMethod(fs, key, (original) => function (fd, ...args) {
|
|
80
|
+
resources.use(fd, mode);
|
|
81
|
+
return original.call(this, fd, ...args);
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
replaceMethod(fs, "closeSync", (original) => function (fd, ...args) {
|
|
86
|
+
resources.use(fd);
|
|
87
|
+
const result = original.call(this, fd, ...args);
|
|
88
|
+
resources.forget(fd);
|
|
89
|
+
return result;
|
|
90
|
+
});
|
|
91
|
+
replaceMethod(fs, "close", (original) => function (fd, callback) {
|
|
92
|
+
resources.use(fd);
|
|
93
|
+
return original.call(this, fd, (error) => { if (!error) resources.forget(fd); callback(error); });
|
|
94
|
+
});
|
|
95
|
+
for (const [name, mode] of [["createReadStream", "read"], ["createWriteStream", "write"]]) {
|
|
96
|
+
replaceMethod(fs, name, (original) => function (file, options) {
|
|
97
|
+
const fd = options?.fd;
|
|
98
|
+
if (fd != null) resources.use(fd, mode);
|
|
99
|
+
else checkPath(check, mode, file);
|
|
100
|
+
const stream = original.call(this, executionPath(file), options);
|
|
101
|
+
resources.record(stream, file, [mode]);
|
|
102
|
+
stream.once("open", (descriptor) => resources.record(descriptor, file, [mode]));
|
|
103
|
+
const methods = mode === "write" ? ["write", "end"] : ["read"];
|
|
104
|
+
for (const method of methods) replaceMethod(stream, method, (fn) => function (...args) {
|
|
105
|
+
resources.use(stream, mode);
|
|
106
|
+
return fn.apply(this, args);
|
|
107
|
+
});
|
|
108
|
+
return stream;
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
const protectBlob = (blob, file) => {
|
|
112
|
+
resources.record(blob, file, ["read"]);
|
|
113
|
+
for (const name of ["text", "bytes", "arrayBuffer", "stream", "slice"]) replaceMethod(blob, name, (fn) => function (...params) {
|
|
114
|
+
resources.use(this, "read");
|
|
115
|
+
const value = fn.apply(this, params);
|
|
116
|
+
return name === "slice" ? protectBlob(value, file) : value;
|
|
117
|
+
});
|
|
118
|
+
return blob;
|
|
119
|
+
};
|
|
120
|
+
replaceMethod(fs, "openAsBlob", (original) => function (file, ...args) {
|
|
121
|
+
return original.call(this, file, ...args).then((blob) => protectBlob(blob, file));
|
|
122
|
+
});
|
|
123
|
+
return resources;
|
|
124
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { checkPath, requestedPath } from "./interpose-paths.js";
|
|
2
|
+
|
|
3
|
+
const { dirname, isAbsolute, resolve } = process.getBuiltinModule("node:path");
|
|
4
|
+
const { fileURLToPath } = process.getBuiltinModule("node:url");
|
|
5
|
+
|
|
6
|
+
export function installModulePermissions(check, userWorker = false) {
|
|
7
|
+
const module = process.getBuiltinModule("node:module");
|
|
8
|
+
const nativeRequire = module.createRequire(import.meta.url);
|
|
9
|
+
const resolveSync = globalThis.Bun?.resolveSync;
|
|
10
|
+
let sealed = false;
|
|
11
|
+
// Resolving a specifier re-enters this hook, because the engine resolver is itself observed. That
|
|
12
|
+
// re-entry is one import still being decided, not another import, so it must not ask again.
|
|
13
|
+
let resolving = false;
|
|
14
|
+
// A user worker's own script reaches its own workerData and parentPort through this module, which
|
|
15
|
+
// #4387 keeps transparent, so only the cell context treats the module itself as a capability.
|
|
16
|
+
const forbidden = userWorker
|
|
17
|
+
? /^(?:bun:ffi|(?:node:)?vm)$/u
|
|
18
|
+
: /^(?:bun:ffi|(?:node:)?(?:vm|worker_threads))$/u;
|
|
19
|
+
const workerThreads = /^(?:node:)?worker_threads$/u;
|
|
20
|
+
// A cell already owns these as globals, so handing them back over require grants nothing, while
|
|
21
|
+
// Worker, parentPort and workerData stay out of reach: the raw Worker would spawn an uninterposed
|
|
22
|
+
// one. Only require can substitute a namespace, so import and getBuiltinModule stay refused.
|
|
23
|
+
const portPrimitives = () =>
|
|
24
|
+
Object.freeze(
|
|
25
|
+
Object.fromEntries(
|
|
26
|
+
["MessageChannel", "MessagePort", "BroadcastChannel"]
|
|
27
|
+
.filter((name) => name in globalThis)
|
|
28
|
+
.map((name) => [name, globalThis[name]]),
|
|
29
|
+
),
|
|
30
|
+
);
|
|
31
|
+
const pathLike = (name) => name.startsWith(".") || isAbsolute(name) || name.startsWith("file:");
|
|
32
|
+
const guard = (source, importer) => {
|
|
33
|
+
const specifier = String(source);
|
|
34
|
+
if (!sealed || resolving) return;
|
|
35
|
+
if (forbidden.test(specifier) || specifier.endsWith(".node")) check(() => ({ unsupported: true }));
|
|
36
|
+
if (!pathLike(specifier)) return;
|
|
37
|
+
const parent = importer?.startsWith("file:") ? fileURLToPath(importer) : importer;
|
|
38
|
+
const base = parent ? dirname(parent) : process.cwd();
|
|
39
|
+
const candidate = specifier.startsWith("file:") ? fileURLToPath(specifier) : specifier;
|
|
40
|
+
let target;
|
|
41
|
+
resolving = true;
|
|
42
|
+
try {
|
|
43
|
+
target = resolveSync ? resolveSync(candidate, base) : nativeRequire.resolve(resolve(base, candidate));
|
|
44
|
+
} catch {
|
|
45
|
+
target = isAbsolute(candidate) ? candidate : resolve(base, candidate);
|
|
46
|
+
} finally {
|
|
47
|
+
resolving = false;
|
|
48
|
+
}
|
|
49
|
+
checkPath(check, "read", target);
|
|
50
|
+
};
|
|
51
|
+
// Only resolution is observed: Bun owns every loader and import attribute.
|
|
52
|
+
if (globalThis.Bun?.plugin) {
|
|
53
|
+
Bun.plugin({
|
|
54
|
+
name: "senpi-cell-permissions",
|
|
55
|
+
setup(build) {
|
|
56
|
+
build.onResolve({ filter: /.*/u }, (args) => { guard(args.path, args.importer); });
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
// The decision comes first. A cell nothing is enforced against sees the engine's own module, so
|
|
61
|
+
// no-rules behaviour is unchanged and matches what import() returns; only a refusal is answered
|
|
62
|
+
// with the port primitives the cell already owns as globals.
|
|
63
|
+
const requireGuard = (specifier, importer) => {
|
|
64
|
+
if (!sealed || !workerThreads.test(String(specifier))) {
|
|
65
|
+
guard(specifier, importer);
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
guard(specifier, importer);
|
|
70
|
+
} catch (error) {
|
|
71
|
+
if (error?.code !== "cell_permission_denied") throw error;
|
|
72
|
+
return portPrimitives();
|
|
73
|
+
}
|
|
74
|
+
return undefined;
|
|
75
|
+
};
|
|
76
|
+
const originalRequire = module.prototype.require;
|
|
77
|
+
module.prototype.require = function (specifier) {
|
|
78
|
+
const substitute = requireGuard(specifier, this.filename);
|
|
79
|
+
if (substitute) return substitute;
|
|
80
|
+
if (specifier === "bun" && globalThis.Bun) return globalThis[Symbol.for("senpi.kernel.bun")] ?? globalThis.Bun;
|
|
81
|
+
return originalRequire.call(this, specifier);
|
|
82
|
+
};
|
|
83
|
+
const originalCreateRequire = module.createRequire;
|
|
84
|
+
module.createRequire = function (...args) {
|
|
85
|
+
const require = originalCreateRequire.apply(this, args);
|
|
86
|
+
const guarded = function (specifier) {
|
|
87
|
+
const substitute = requireGuard(specifier, requestedPath(args[0]) ?? String(args[0]));
|
|
88
|
+
if (substitute) return substitute;
|
|
89
|
+
if (specifier === "bun" && globalThis.Bun) return globalThis[Symbol.for("senpi.kernel.bun")] ?? globalThis.Bun;
|
|
90
|
+
return require(specifier);
|
|
91
|
+
};
|
|
92
|
+
Object.assign(guarded, require);
|
|
93
|
+
guarded.cache = new Proxy(require.cache, {
|
|
94
|
+
set(target, name, value) { check(() => ({ unsupported: true })); return Reflect.set(target, name, value); },
|
|
95
|
+
deleteProperty(target, name) { check(() => ({ unsupported: true })); return Reflect.deleteProperty(target, name); },
|
|
96
|
+
defineProperty(target, name, descriptor) { check(() => ({ unsupported: true })); return Reflect.defineProperty(target, name, descriptor); },
|
|
97
|
+
});
|
|
98
|
+
return guarded;
|
|
99
|
+
};
|
|
100
|
+
const getBuiltin = process.getBuiltinModule;
|
|
101
|
+
process.getBuiltinModule = function (name) {
|
|
102
|
+
if (sealed && forbidden.test(name)) check(() => ({ unsupported: true }));
|
|
103
|
+
return getBuiltin.call(this, name);
|
|
104
|
+
};
|
|
105
|
+
Object.defineProperty(globalThis, Symbol.for("senpi.kernel.permissionImport"), { value: guard });
|
|
106
|
+
return () => { sealed = true; };
|
|
107
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { createPathIdentity, PATH_IDENTITY_VERSION } from "../shared/path-identity.mjs";
|
|
2
|
+
|
|
3
|
+
const path = process.getBuiltinModule("node:path");
|
|
4
|
+
const fs = process.getBuiltinModule("node:fs");
|
|
5
|
+
const { fileURLToPath } = process.getBuiltinModule("node:url");
|
|
6
|
+
|
|
7
|
+
// Captured at module evaluation, before any interposition or cwd patch replaces them: the identity
|
|
8
|
+
// must resolve with the engine's own native realpath and must never reach the live `path.resolve`
|
|
9
|
+
// that worker-cwd.js rebinds to the session root (design N11).
|
|
10
|
+
const identity = createPathIdentity({
|
|
11
|
+
realpathNative: fs.realpathSync.native,
|
|
12
|
+
lstat: fs.lstatSync,
|
|
13
|
+
readlink: fs.readlinkSync,
|
|
14
|
+
pathApi: {
|
|
15
|
+
join: path.join,
|
|
16
|
+
dirname: path.dirname,
|
|
17
|
+
basename: path.basename,
|
|
18
|
+
isAbsolute: path.isAbsolute,
|
|
19
|
+
sep: path.sep,
|
|
20
|
+
win32Resolve: path.win32.resolve,
|
|
21
|
+
},
|
|
22
|
+
platform: process.platform,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
export function requestedPath(value) {
|
|
26
|
+
if (value instanceof URL) return fileURLToPath(value);
|
|
27
|
+
if (value instanceof Uint8Array) {
|
|
28
|
+
const text = Buffer.from(value).toString();
|
|
29
|
+
return Buffer.from(text).equals(value) ? text : undefined;
|
|
30
|
+
}
|
|
31
|
+
return typeof value === "string" ? value : undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function executionPath(value) {
|
|
35
|
+
if (typeof value === "string") return path.resolve(process.cwd(), value);
|
|
36
|
+
if (value instanceof Uint8Array && !path.isAbsolute(Buffer.from(value).toString("latin1"))) {
|
|
37
|
+
return Buffer.concat([Buffer.from(process.cwd() + path.sep), value]);
|
|
38
|
+
}
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The written spelling, the executed spelling, and the kernel-computed canonical form (or why there is none). */
|
|
43
|
+
function pathIdentityInput(value, executed = requestedPath(executionPath(value))) {
|
|
44
|
+
const requested = requestedPath(value);
|
|
45
|
+
if (requested === undefined || executed === undefined) return { input: { path: requested }, unsupported: true };
|
|
46
|
+
const input = { path: requested, executedPath: executed };
|
|
47
|
+
try {
|
|
48
|
+
const { path: canonical, foldCase } = identity.canonical(executed);
|
|
49
|
+
input.canonical = { v: PATH_IDENTITY_VERSION, path: canonical, foldCase };
|
|
50
|
+
} catch (error) {
|
|
51
|
+
input.unresolvable = typeof error?.code === "string" ? error.code : "EUNKNOWN";
|
|
52
|
+
}
|
|
53
|
+
return { input, unsupported: false };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function checkPath(check, tool, value, options = {}) {
|
|
57
|
+
try {
|
|
58
|
+
check(() => {
|
|
59
|
+
const { input, unsupported } = pathIdentityInput(value, options.executed);
|
|
60
|
+
return { call: { tool, input, cwd: options.cwd ?? process.cwd() }, unsupported };
|
|
61
|
+
});
|
|
62
|
+
} catch (error) {
|
|
63
|
+
if (error?.code === "cell_permission_denied" && typeof value === "string") error.message += `: ${value}`;
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function replaceMethod(owner, name, wrap) {
|
|
69
|
+
const original = owner?.[name];
|
|
70
|
+
if (typeof original !== "function") return;
|
|
71
|
+
const wrapped = wrap(original);
|
|
72
|
+
for (const key of Reflect.ownKeys(original)) {
|
|
73
|
+
if (key === "length" || key === "name" || key === "prototype" || Object.hasOwn(wrapped, key)) continue;
|
|
74
|
+
Object.defineProperty(wrapped, key, Object.getOwnPropertyDescriptor(original, key));
|
|
75
|
+
}
|
|
76
|
+
Object.defineProperty(owner, name, {
|
|
77
|
+
...(Object.getOwnPropertyDescriptor(owner, name) ?? { configurable: true, writable: true, enumerable: false }),
|
|
78
|
+
value: wrapped,
|
|
79
|
+
});
|
|
80
|
+
}
|