@code-yeongyu/senpi-codemode 2026.10.27-1 → 2026.10.30-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/CHANGELOG.md +46 -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/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
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,51 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [2026.10.30-1] - 2026-10-30
|
|
4
|
+
|
|
5
|
+
### Breaking Changes
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added the host-decider core for JS eval permission checks on `child_process.spawnSync`, `Bun.spawnSync`, and `fs.writeFileSync`, with per-invocation authority, bounded shared-memory checks, instrumented Workers and fail-closed revocation. Permission-extension integration and broader API coverage follow separately ([#4387](https://github.com/code-yeongyu/senpi/pull/4387)).
|
|
10
|
+
- No-deny async continuations and Workers remain usable across cells, including after a cell times out or is interrupted. Worker data, messages, transferred MessagePorts and lifecycle events are preserved; a Worker message the host cannot forward arrives as `messageerror`; and interrupted permission checks release their shared slots.
|
|
11
|
+
- Known limitation: reads performed by the runtime's native module resolver (bare packages, tsconfig aliases, package.json #imports, symlinked packages) are not covered by this work; tracked in #4380.
|
|
12
|
+
- Refuse cross-session JavaScript and Python kernel tools before their bodies run when either session enforces a deny rule, and mint invoker-scoped JS tokens when neither enforces ([#4390](https://github.com/code-yeongyu/senpi/pull/4390), part of #4309).
|
|
13
|
+
- Known limitation: reads performed by the runtime's native module resolver (bare packages, tsconfig aliases, package.json #imports, symlinked packages) are not covered by this work; tracked in #4380.
|
|
14
|
+
- Extend eval permission checks across JS filesystem, process, Bun resource, and SQLite APIs, including descriptor grants and relative/absolute module imports ([#4391](https://github.com/code-yeongyu/senpi/pull/4391)).
|
|
15
|
+
- Known limitation: reads performed by the runtime's native module resolver (bare packages, tsconfig aliases, package.json #imports, symlinked packages) are not covered by this work; tracked in #4380.
|
|
16
|
+
- Known limitation: in natively loaded dependencies, Bun.plugin registration and non-literal eval are not refused (Bun 1.4.4 makes Bun.plugin immutable); code loaded that way still runs under the enforced spawn and write checks; tracked in #4380.
|
|
17
|
+
- Known limitation: file reads through Bun.fetch on file: URLs (immutable in Bun 1.4.4) and native consumers of a handle obtained that way. Writes through such a handle are refused at each enumerated write sink: filesystem operations, Bun.write destinations and parts, BunFile write/writer/delete/unlink, and every Bun.spawn/Bun.spawnSync redirect, meaning the stdout/stderr keys and each stdio[] slot, including descriptors at index 3 and above. Every one of those sinks is a row in the operation sink table and the mutation driver, so a sink that is not listed there is not proven.
|
|
18
|
+
- Eval cells now send the path they execute in addition to the path as written, together with its canonical form, so permission rules for `read`, `edit`, `list` and `grep` paths apply to the same file under every spelling that resolves to it; a module's own imports and top-level file access are decided under the importing cell's authority instead of being refused as unavailable ([#4391](https://github.com/code-yeongyu/senpi/pull/4391)).
|
|
19
|
+
- Known limitation: path rules do not follow hard links; tracked in #4576.
|
|
20
|
+
- Enforce host-supplied deny decisions inside Python eval cells and callbacks, including file modes, descriptor grants, process APIs, SQLite, module sources, and package resources; refuse Ruby and Julia cells while enforcement is active ([#4392](https://github.com/code-yeongyu/senpi/pull/4392), part of #4309).
|
|
21
|
+
- Revoke Python invocation authority immediately and wait for observed interpreter retirement before admitting a replacement ([#4392](https://github.com/code-yeongyu/senpi/pull/4392)).
|
|
22
|
+
- Keep Python permission checks intact after stdlib rebinding, guard native aliases and direct extension entry points, and preserve cross-cell background I/O when no deny applies ([#4392](https://github.com/code-yeongyu/senpi/pull/4392)).
|
|
23
|
+
- Leave the interpreter's own import registries in place while guarding them, so C-level imports such as `pickle`, `datetime.strptime` and `xml.etree.ElementTree` keep working inside Python cells ([#4392](https://github.com/code-yeongyu/senpi/pull/4392)).
|
|
24
|
+
- Known limitation: reads performed by the runtime's native module resolver (bare packages, tsconfig aliases, package.json #imports, symlinked packages) are not covered by this work; tracked in #4380.
|
|
25
|
+
- Python eval cells now send the path they execute in addition to the path as written, so permission rules for `read`, `edit`, `list` and `grep` paths apply to the same file under every spelling a Python cell resolves to it, including relative spellings after `os.chdir`; a path spelling that is not valid UTF-8 is refused as uncheckable ([#4392](https://github.com/code-yeongyu/senpi/pull/4392)).
|
|
26
|
+
- Python eval cells supply the same versioned native path identity as JavaScript cells, preserving identity-based decisions when the host resolver is unavailable; inputs without an exact local representation retain host fallback ([#4392](https://github.com/code-yeongyu/senpi/pull/4392)).
|
|
27
|
+
|
|
28
|
+
### Changed
|
|
29
|
+
|
|
30
|
+
### Fixed
|
|
31
|
+
|
|
32
|
+
- On macOS, eval cells that open a `Bun.WebView` no longer crash the process (SIGSEGV) when another kernel uses one too, WebViews keep working after a kernel reset, and `goBack()`/`goForward()` work on every eval-cell view ([#4797](https://github.com/code-yeongyu/senpi/pull/4797))
|
|
33
|
+
- The Python eval kernel starts again on interpreters built without `ctypes`: it then sends no canonical path form, and the host decides as it does for any request without one ([#4895](https://github.com/code-yeongyu/senpi/pull/4895), fixes #4894).
|
|
34
|
+
|
|
35
|
+
### Removed
|
|
36
|
+
|
|
37
|
+
## [2026.10.27-2] - 2026-10-27
|
|
38
|
+
|
|
39
|
+
### Breaking Changes
|
|
40
|
+
|
|
41
|
+
### Added
|
|
42
|
+
|
|
43
|
+
### Changed
|
|
44
|
+
|
|
45
|
+
### Fixed
|
|
46
|
+
|
|
47
|
+
### Removed
|
|
48
|
+
|
|
3
49
|
## [2026.10.27-1] - 2026-10-26
|
|
4
50
|
|
|
5
51
|
### Breaking Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@code-yeongyu/senpi-codemode",
|
|
3
|
-
"version": "2026.10.
|
|
3
|
+
"version": "2026.10.30-1",
|
|
4
4
|
"description": "Source-only senpi extension package for codemode evaluation tools",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -33,15 +33,15 @@
|
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
35
|
"@babel/parser": "8.0.4",
|
|
36
|
-
"@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.10.
|
|
36
|
+
"@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.10.30-1",
|
|
37
37
|
"quickjs-wasi": "3.6.2",
|
|
38
38
|
"typebox": "1.3.34"
|
|
39
39
|
},
|
|
40
40
|
"peerDependencies": {
|
|
41
|
-
"@code-yeongyu/senpi": "2026.10.
|
|
41
|
+
"@code-yeongyu/senpi": "2026.10.30-1"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
|
-
"@code-yeongyu/senpi": "2026.10.
|
|
44
|
+
"@code-yeongyu/senpi": "2026.10.30-1"
|
|
45
45
|
},
|
|
46
46
|
"keywords": [
|
|
47
47
|
"senpi",
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { once } from "node:events";
|
|
2
2
|
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
|
3
3
|
import type { AddressInfo, Socket } from "node:net";
|
|
4
|
+
import { permissionCheck } from "./permission-check.ts";
|
|
4
5
|
import { type BridgeError, generateBridgeToken, verifyBridgeToken } from "./protocol.ts";
|
|
5
6
|
|
|
6
7
|
const DEFAULT_BODY_LIMIT_BYTES = 1024 * 1024;
|
|
@@ -42,7 +43,7 @@ export interface BridgeServerHandle {
|
|
|
42
43
|
}
|
|
43
44
|
|
|
44
45
|
type JsonReply = { ok: true; value: unknown } | { ok: false; error: BridgeError };
|
|
45
|
-
type Route = "/call" | "/emit" | "/completion";
|
|
46
|
+
type Route = "/call" | "/emit" | "/completion" | "/permission_check";
|
|
46
47
|
|
|
47
48
|
export async function startBridgeServer(options: BridgeServerOptions): Promise<BridgeServerHandle> {
|
|
48
49
|
const token = options.token ?? generateBridgeToken();
|
|
@@ -94,7 +95,7 @@ async function handleRequest(
|
|
|
94
95
|
return;
|
|
95
96
|
}
|
|
96
97
|
const auth = parseBearerToken(request.headers.authorization);
|
|
97
|
-
if (!auth || !verifyBridgeToken(token, auth).ok) {
|
|
98
|
+
if (route !== "/permission_check" && (!auth || !verifyBridgeToken(token, auth).ok)) {
|
|
98
99
|
sendJson(response, 401, { ok: false, error: transportError("unauthorized", "Bridge authorization failed") });
|
|
99
100
|
return;
|
|
100
101
|
}
|
|
@@ -104,6 +105,14 @@ async function handleRequest(
|
|
|
104
105
|
sendJson(response, parsed.status, { ok: false, error: transportError(parsed.code, parsed.message) });
|
|
105
106
|
return;
|
|
106
107
|
}
|
|
108
|
+
if (route === "/permission_check") {
|
|
109
|
+
try {
|
|
110
|
+
sendJson(response, 200, { ok: true, value: await permissionCheck(parsed.value) });
|
|
111
|
+
} catch (error) {
|
|
112
|
+
sendJson(response, 200, { ok: false, error: bridgeError(error) });
|
|
113
|
+
}
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
107
116
|
|
|
108
117
|
if (route === "/emit") {
|
|
109
118
|
const event = parseEmitEvent(parsed.value);
|
|
@@ -209,7 +218,7 @@ function parseEmitEvent(value: unknown): { ok: true; event: BridgeHttpEmitEvent
|
|
|
209
218
|
|
|
210
219
|
function routeFromUrl(rawUrl: string): Route | undefined {
|
|
211
220
|
const path = new URL(rawUrl, "http://127.0.0.1").pathname;
|
|
212
|
-
if (path === "/call" || path === "/emit" || path === "/completion") return path;
|
|
221
|
+
if (path === "/call" || path === "/emit" || path === "/completion" || path === "/permission_check") return path;
|
|
213
222
|
return undefined;
|
|
214
223
|
}
|
|
215
224
|
|
|
@@ -221,8 +230,12 @@ function parseBearerToken(header: string | undefined): string | undefined {
|
|
|
221
230
|
|
|
222
231
|
function sendJson(response: ServerResponse, status: number, body: JsonReply): void {
|
|
223
232
|
if (response.destroyed) return;
|
|
224
|
-
|
|
225
|
-
response.
|
|
233
|
+
const encoded = JSON.stringify(body);
|
|
234
|
+
response.writeHead(status, {
|
|
235
|
+
"content-type": "application/json; charset=utf-8",
|
|
236
|
+
"content-length": Buffer.byteLength(encoded),
|
|
237
|
+
});
|
|
238
|
+
response.end(encoded);
|
|
226
239
|
}
|
|
227
240
|
|
|
228
241
|
function bridgeError(error: unknown): BridgeError {
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { CellDecision, CellOperation } from "../kernels/js/permission-slots.mjs";
|
|
2
|
+
|
|
3
|
+
const checks = new Map<string, (call: CellOperation) => Promise<CellDecision>>();
|
|
4
|
+
|
|
5
|
+
/** Only the host registers invocation capabilities. The ordinary bridge bearer is not one. */
|
|
6
|
+
export function registerPermissionCheck(
|
|
7
|
+
token: string,
|
|
8
|
+
check: (call: CellOperation) => Promise<CellDecision>,
|
|
9
|
+
): () => void {
|
|
10
|
+
checks.set(token, check);
|
|
11
|
+
return () => checks.delete(token);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function permissionCheck(body: unknown): Promise<unknown> {
|
|
15
|
+
if (
|
|
16
|
+
typeof body !== "object" ||
|
|
17
|
+
body === null ||
|
|
18
|
+
!("token" in body) ||
|
|
19
|
+
typeof body.token !== "string" ||
|
|
20
|
+
!("nonce" in body) ||
|
|
21
|
+
typeof body.nonce !== "string" ||
|
|
22
|
+
!("call" in body)
|
|
23
|
+
) {
|
|
24
|
+
throw new Error("Invalid permission request");
|
|
25
|
+
}
|
|
26
|
+
const call = body.call;
|
|
27
|
+
if (
|
|
28
|
+
typeof call !== "object" ||
|
|
29
|
+
call === null ||
|
|
30
|
+
!("tool" in call) ||
|
|
31
|
+
!("input" in call) ||
|
|
32
|
+
typeof call.input !== "object" ||
|
|
33
|
+
call.input === null
|
|
34
|
+
)
|
|
35
|
+
throw new Error("Invalid permission operation");
|
|
36
|
+
if (call.tool !== "bash" && call.tool !== "write" && call.tool !== "read" && call.tool !== "ls")
|
|
37
|
+
throw new Error("Invalid permission tool");
|
|
38
|
+
const input = call.input;
|
|
39
|
+
if (
|
|
40
|
+
call.tool === "bash"
|
|
41
|
+
? !("command" in input && typeof input.command === "string")
|
|
42
|
+
: !("path" in input && typeof input.path === "string")
|
|
43
|
+
)
|
|
44
|
+
throw new Error("Invalid permission input");
|
|
45
|
+
if ("cwd" in call && typeof call.cwd !== "string") throw new Error("Invalid execution cwd");
|
|
46
|
+
const check = checks.get(body.token);
|
|
47
|
+
if (!check) throw new Error("Permission authority unavailable");
|
|
48
|
+
const canonical = "canonical" in input ? input.canonical : undefined;
|
|
49
|
+
const validCanonical =
|
|
50
|
+
typeof canonical === "object" &&
|
|
51
|
+
canonical !== null &&
|
|
52
|
+
"v" in canonical &&
|
|
53
|
+
typeof canonical.v === "number" &&
|
|
54
|
+
"path" in canonical &&
|
|
55
|
+
typeof canonical.path === "string" &&
|
|
56
|
+
"foldCase" in canonical &&
|
|
57
|
+
typeof canonical.foldCase === "boolean"
|
|
58
|
+
? { v: canonical.v, path: canonical.path, foldCase: canonical.foldCase }
|
|
59
|
+
: undefined;
|
|
60
|
+
const operation: CellOperation = {
|
|
61
|
+
tool: call.tool,
|
|
62
|
+
input: {
|
|
63
|
+
...("command" in input && typeof input.command === "string" ? { command: input.command } : {}),
|
|
64
|
+
...("path" in input && typeof input.path === "string" ? { path: input.path } : {}),
|
|
65
|
+
...("executedPath" in input && typeof input.executedPath === "string"
|
|
66
|
+
? { executedPath: input.executedPath }
|
|
67
|
+
: {}),
|
|
68
|
+
...(validCanonical === undefined ? {} : { canonical: validCanonical }),
|
|
69
|
+
...("unresolvable" in input && typeof input.unresolvable === "string"
|
|
70
|
+
? { unresolvable: input.unresolvable }
|
|
71
|
+
: {}),
|
|
72
|
+
},
|
|
73
|
+
...("cwd" in call && typeof call.cwd === "string" ? { cwd: call.cwd } : {}),
|
|
74
|
+
};
|
|
75
|
+
return { nonce: body.nonce, decision: await check(operation) };
|
|
76
|
+
}
|
package/src/bridge/protocol.ts
CHANGED
|
@@ -16,6 +16,13 @@ const bridgeErrorSchema = Type.Object({
|
|
|
16
16
|
message: Type.String(),
|
|
17
17
|
stack: Type.Optional(Type.String()),
|
|
18
18
|
code: Type.Optional(Type.String()),
|
|
19
|
+
permissionRule: Type.Optional(
|
|
20
|
+
Type.Object({
|
|
21
|
+
permission: Type.String(),
|
|
22
|
+
pattern: Type.String(),
|
|
23
|
+
layer: Type.String(),
|
|
24
|
+
}),
|
|
25
|
+
),
|
|
19
26
|
});
|
|
20
27
|
|
|
21
28
|
const statusEventSchema = Type.Object({ op: Type.String() }, { additionalProperties: true });
|
package/src/kernels/jl/kernel.ts
CHANGED
|
@@ -2,11 +2,13 @@ import { join } from "node:path";
|
|
|
2
2
|
import type { BridgeConnectionConfig, KernelToHostMessage } from "../../bridge/protocol.ts";
|
|
3
3
|
import type { SessionEnvironment } from "../session-env.ts";
|
|
4
4
|
import type { KernelLifecycle } from "../shared/kernel-death.ts";
|
|
5
|
+
import type { InvocationPermissions } from "../shared/invocation-authority.ts";
|
|
5
6
|
import { readProcessGroupCpuTime } from "../shared/process-group-cpu.ts";
|
|
6
7
|
import { type CodemodeRuntimeAssetEnvironment, requireCodemodeRuntimeAsset } from "../shared/runtime-asset.ts";
|
|
7
8
|
import { SubprocessKernel, type SubprocessKernelMemory, type SubprocessSpawn } from "../shared/subprocess-kernel.ts";
|
|
8
9
|
|
|
9
10
|
export interface JuliaKernelStartOptions extends KernelLifecycle {
|
|
11
|
+
readonly permissions?: InvocationPermissions;
|
|
10
12
|
readonly cwd: string;
|
|
11
13
|
readonly sessionId: string;
|
|
12
14
|
readonly connection: BridgeConnectionConfig;
|
|
@@ -55,6 +57,7 @@ export class JuliaKernel extends SubprocessKernel {
|
|
|
55
57
|
onMessage: options.onMessage,
|
|
56
58
|
memory: options.memory && { language: "jl", ...options.memory },
|
|
57
59
|
onDeath: options.onDeath,
|
|
60
|
+
permissions: options.permissions,
|
|
58
61
|
startup: { label: "Julia", readGroupCpuTime: readProcessGroupCpuTime },
|
|
59
62
|
});
|
|
60
63
|
}
|
|
@@ -28,6 +28,9 @@ import { ToolCallQueue } from "./tool-call-queue.ts";
|
|
|
28
28
|
import { WorkerChildren } from "./worker-children.ts";
|
|
29
29
|
import { WorkerRecovery } from "./worker-recovery.ts";
|
|
30
30
|
import { WorkerSlot } from "./worker-slot.ts";
|
|
31
|
+
import { KernelPermissionHost } from "./permission-host.ts";
|
|
32
|
+
|
|
33
|
+
export { revokeSessionKernels } from "./permission-host.ts";
|
|
31
34
|
|
|
32
35
|
export { JavaScriptKernelClosedError, type JavaScriptKernelMode, type JavaScriptRunInput } from "./kernel-contract.ts";
|
|
33
36
|
export type { JavaScriptMemoryReading } from "./kernel-memory-bridge.ts";
|
|
@@ -47,6 +50,7 @@ export class JavaScriptKernel {
|
|
|
47
50
|
readonly #options: JavaScriptKernelOptions;
|
|
48
51
|
readonly #moduleLoader: LocalModuleLoader;
|
|
49
52
|
readonly #slot: WorkerSlot;
|
|
53
|
+
readonly #permissions: KernelPermissionHost;
|
|
50
54
|
#lifecycle: LifecycleState = "open";
|
|
51
55
|
#activation: Promise<void> | null = null;
|
|
52
56
|
#closePromise: Promise<void> | null = null;
|
|
@@ -54,6 +58,7 @@ export class JavaScriptKernel {
|
|
|
54
58
|
readonly #kernelTools = new KernelToolHostPump(
|
|
55
59
|
(message) => this.#slot.postMessage(message),
|
|
56
60
|
() => this.#lifecycle === "open" && this.#slot.present,
|
|
61
|
+
(request, options) => this.#permissions.invocation(request, options),
|
|
57
62
|
);
|
|
58
63
|
readonly #toolCalls = new ToolCallQueue();
|
|
59
64
|
readonly #children: WorkerChildren;
|
|
@@ -68,6 +73,10 @@ export class JavaScriptKernel {
|
|
|
68
73
|
|
|
69
74
|
constructor(options: JavaScriptKernelOptions) {
|
|
70
75
|
this.#options = options;
|
|
76
|
+
this.#permissions = new KernelPermissionHost(options, async () => {
|
|
77
|
+
this.#runs.settleAll("Permission rules changed; kernel revoked");
|
|
78
|
+
return await this.#terminate();
|
|
79
|
+
});
|
|
71
80
|
this.#activeCell = new ActiveCellControl({
|
|
72
81
|
runs: this.#runs,
|
|
73
82
|
bounds: options.interruptBounds ?? DEFAULT_INTERRUPT_BOUNDS,
|
|
@@ -79,11 +88,15 @@ export class JavaScriptKernel {
|
|
|
79
88
|
this.#memory = new JavaScriptKernelMemory(options, () => this.#slot.processPid);
|
|
80
89
|
this.#children = new WorkerChildren(options.collectOrphanedChildren);
|
|
81
90
|
this.#moduleLoader = new LocalModuleLoader(options);
|
|
82
|
-
this.#slot = new WorkerSlot(
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
91
|
+
this.#slot = new WorkerSlot(
|
|
92
|
+
options,
|
|
93
|
+
{
|
|
94
|
+
isOpen: () => this.#lifecycle === "open",
|
|
95
|
+
onMessage: (message) => this.#handleMessage(message),
|
|
96
|
+
onCrash: (error) => this.#handleCrash(error),
|
|
97
|
+
},
|
|
98
|
+
this.#permissions,
|
|
99
|
+
);
|
|
87
100
|
}
|
|
88
101
|
|
|
89
102
|
get mode(): JavaScriptKernelMode {
|
|
@@ -114,15 +127,17 @@ export class JavaScriptKernel {
|
|
|
114
127
|
return this.#kernelTools.describe(names);
|
|
115
128
|
}
|
|
116
129
|
|
|
117
|
-
invokeKernelTool(
|
|
130
|
+
async invokeKernelTool(
|
|
118
131
|
request: KernelToolsInvokeRequest,
|
|
119
132
|
options?: AbortSignal | KernelToolsInvokeOptions,
|
|
120
133
|
): Promise<unknown> {
|
|
121
|
-
|
|
134
|
+
await this.#permissions.admit();
|
|
135
|
+
return await this.#kernelTools.invoke(request, options);
|
|
122
136
|
}
|
|
123
137
|
|
|
124
138
|
async run(input: JavaScriptRunInput): Promise<ResultMessage> {
|
|
125
139
|
assertJavaScriptKernelOpen(this.#lifecycle, "run");
|
|
140
|
+
this.#permissions.assertSessionLive();
|
|
126
141
|
const promise = this.#runs.enqueue(input);
|
|
127
142
|
this.#activate();
|
|
128
143
|
return await promise;
|
|
@@ -189,6 +204,7 @@ export class JavaScriptKernel {
|
|
|
189
204
|
if (recovery) await recovery;
|
|
190
205
|
await this.#terminate();
|
|
191
206
|
})().finally(() => {
|
|
207
|
+
this.#permissions.dispose();
|
|
192
208
|
this.#lifecycle = "closed";
|
|
193
209
|
});
|
|
194
210
|
this.#closePromise = closePromise;
|
|
@@ -206,6 +222,7 @@ export class JavaScriptKernel {
|
|
|
206
222
|
}
|
|
207
223
|
|
|
208
224
|
async #ensureReady(): Promise<void> {
|
|
225
|
+
await this.#permissions.admit();
|
|
209
226
|
assertJavaScriptKernelOpen(this.#lifecycle, "run");
|
|
210
227
|
try {
|
|
211
228
|
await this.#slot.ensureReady();
|
|
@@ -217,7 +234,7 @@ export class JavaScriptKernel {
|
|
|
217
234
|
}
|
|
218
235
|
|
|
219
236
|
#startNext(): void {
|
|
220
|
-
if (this.#lifecycle !== "open" || this.#runs.active || !this.#slot.present) return;
|
|
237
|
+
if (this.#permissions.blocked || this.#lifecycle !== "open" || this.#runs.active || !this.#slot.present) return;
|
|
221
238
|
const next = this.#runs.startNext(performance.now());
|
|
222
239
|
if (!next) return;
|
|
223
240
|
const dispatch = dispatchCell(next.input, this.#moduleLoader, this.#options);
|
|
@@ -254,6 +271,7 @@ export class JavaScriptKernel {
|
|
|
254
271
|
}
|
|
255
272
|
|
|
256
273
|
#handleCrash(error: Error): void {
|
|
274
|
+
if (this.#permissions.blocked) return;
|
|
257
275
|
const active = this.#runs.active;
|
|
258
276
|
if (!active && this.#slot.startingUp) return;
|
|
259
277
|
this.#activeCell.disarm();
|
|
@@ -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
|
+
}
|