@code-yeongyu/senpi-codemode 2026.10.27-2 → 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.
Files changed (65) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/package.json +4 -4
  3. package/src/bridge/http-server.ts +18 -5
  4. package/src/bridge/permission-check.ts +76 -0
  5. package/src/bridge/protocol.ts +7 -0
  6. package/src/kernels/jl/kernel.ts +3 -0
  7. package/src/kernels/js/context-manager.ts +26 -8
  8. package/src/kernels/js/inline-worker-entry.js +1 -23
  9. package/src/kernels/js/inline-worker.ts +7 -2
  10. package/src/kernels/js/interpose-bun.js +220 -0
  11. package/src/kernels/js/interpose-capabilities.js +29 -0
  12. package/src/kernels/js/interpose-code.js +8 -0
  13. package/src/kernels/js/interpose-fs.js +124 -0
  14. package/src/kernels/js/interpose-modules.js +107 -0
  15. package/src/kernels/js/interpose-paths.js +80 -0
  16. package/src/kernels/js/interpose-process.js +97 -0
  17. package/src/kernels/js/interpose-resources.js +59 -0
  18. package/src/kernels/js/interpose-sqlite.js +104 -0
  19. package/src/kernels/js/interpose.js +86 -0
  20. package/src/kernels/js/kernel-contract.ts +31 -0
  21. package/src/kernels/js/kernel-tools-pump.js +4 -2
  22. package/src/kernels/js/local-module-loader.ts +9 -4
  23. package/src/kernels/js/permission-authority.d.mts +10 -0
  24. package/src/kernels/js/permission-authority.mjs +11 -0
  25. package/src/kernels/js/permission-core.md +114 -0
  26. package/src/kernels/js/permission-host.ts +388 -0
  27. package/src/kernels/js/permission-runtime.js +205 -0
  28. package/src/kernels/js/permission-sessions.ts +46 -0
  29. package/src/kernels/js/permission-slots.d.mts +51 -0
  30. package/src/kernels/js/permission-slots.mjs +133 -0
  31. package/src/kernels/js/permission-worker-entry.js +1 -0
  32. package/src/kernels/js/permission-worker-frames.ts +55 -0
  33. package/src/kernels/js/process-tree.js +9 -1
  34. package/src/kernels/js/rewrite-imports.ts +44 -0
  35. package/src/kernels/js/windows-tree-kill.js +10 -2
  36. package/src/kernels/js/worker-core.d.ts +2 -0
  37. package/src/kernels/js/worker-core.js +5 -2
  38. package/src/kernels/js/worker-cwd.js +14 -8
  39. package/src/kernels/js/worker-entry.js +1 -23
  40. package/src/kernels/js/worker-file-helpers.js +58 -0
  41. package/src/kernels/js/worker-host.ts +24 -6
  42. package/src/kernels/js/worker-package-resolve.js +10 -2
  43. package/src/kernels/js/worker-runtime.js +5 -65
  44. package/src/kernels/js/worker-slot.ts +5 -1
  45. package/src/kernels/js/worker-startup.ts +7 -5
  46. package/src/kernels/js/worker-webview.js +17 -16
  47. package/src/kernels/py/interpose.py +765 -0
  48. package/src/kernels/py/kernel-contract.ts +4 -0
  49. package/src/kernels/py/kernel-tools-host.ts +5 -1
  50. package/src/kernels/py/kernel.ts +45 -5
  51. package/src/kernels/py/path_identity.py +162 -0
  52. package/src/kernels/py/path_identity_posix.py +74 -0
  53. package/src/kernels/py/path_identity_windows.py +93 -0
  54. package/src/kernels/py/permission-core.md +103 -0
  55. package/src/kernels/py/permission-host.ts +198 -0
  56. package/src/kernels/py/permission_bridge.py +145 -0
  57. package/src/kernels/py/prelude.py +40 -5
  58. package/src/kernels/py/transport.ts +15 -8
  59. package/src/kernels/rb/kernel.ts +3 -0
  60. package/src/kernels/shared/invocation-authority.ts +59 -0
  61. package/src/kernels/shared/kernel-tools-pump.ts +22 -1
  62. package/src/kernels/shared/path-identity.d.mts +42 -0
  63. package/src/kernels/shared/path-identity.mjs +149 -0
  64. package/src/kernels/shared/subprocess-contract.ts +2 -0
  65. package/src/kernels/shared/subprocess-kernel.ts +28 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,39 @@
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
+
3
37
  ## [2026.10.27-2] - 2026-10-27
4
38
 
5
39
  ### Breaking Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@code-yeongyu/senpi-codemode",
3
- "version": "2026.10.27-2",
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.27-2",
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.27-2"
41
+ "@code-yeongyu/senpi": "2026.10.30-1"
42
42
  },
43
43
  "devDependencies": {
44
- "@code-yeongyu/senpi": "2026.10.27-2"
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
- response.writeHead(status, { "content-type": "application/json; charset=utf-8" });
225
- response.end(JSON.stringify(body));
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
+ }
@@ -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 });
@@ -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(options, {
83
- isOpen: () => this.#lifecycle === "open",
84
- onMessage: (message) => this.#handleMessage(message),
85
- onCrash: (error) => this.#handleCrash(error),
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
- return this.#kernelTools.invoke(request, options);
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 { parentPort, workerData } from "node:worker_threads";
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(cwd: string, parallelPoolWidth: number): WorkerLike {
31
- return spawnNodeWorker(resolveInlineWorkerEntryUrl(), cwd, parallelPoolWidth, "inline");
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
+ }