@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.
Files changed (66) hide show
  1. package/CHANGELOG.md +49 -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/group-signal-notice.js +277 -1
  9. package/src/kernels/js/inline-worker-entry.js +1 -23
  10. package/src/kernels/js/inline-worker.ts +7 -2
  11. package/src/kernels/js/interpose-bun.js +220 -0
  12. package/src/kernels/js/interpose-capabilities.js +29 -0
  13. package/src/kernels/js/interpose-code.js +8 -0
  14. package/src/kernels/js/interpose-fs.js +124 -0
  15. package/src/kernels/js/interpose-modules.js +107 -0
  16. package/src/kernels/js/interpose-paths.js +80 -0
  17. package/src/kernels/js/interpose-process.js +97 -0
  18. package/src/kernels/js/interpose-resources.js +59 -0
  19. package/src/kernels/js/interpose-sqlite.js +104 -0
  20. package/src/kernels/js/interpose.js +86 -0
  21. package/src/kernels/js/kernel-contract.ts +31 -0
  22. package/src/kernels/js/kernel-tools-pump.js +4 -2
  23. package/src/kernels/js/local-module-loader.ts +9 -4
  24. package/src/kernels/js/permission-authority.d.mts +10 -0
  25. package/src/kernels/js/permission-authority.mjs +11 -0
  26. package/src/kernels/js/permission-core.md +114 -0
  27. package/src/kernels/js/permission-host.ts +388 -0
  28. package/src/kernels/js/permission-runtime.js +205 -0
  29. package/src/kernels/js/permission-sessions.ts +46 -0
  30. package/src/kernels/js/permission-slots.d.mts +51 -0
  31. package/src/kernels/js/permission-slots.mjs +133 -0
  32. package/src/kernels/js/permission-worker-entry.js +1 -0
  33. package/src/kernels/js/permission-worker-frames.ts +55 -0
  34. package/src/kernels/js/process-tree.js +9 -1
  35. package/src/kernels/js/rewrite-imports.ts +44 -0
  36. package/src/kernels/js/windows-tree-kill.js +10 -2
  37. package/src/kernels/js/worker-core.d.ts +2 -0
  38. package/src/kernels/js/worker-core.js +5 -2
  39. package/src/kernels/js/worker-cwd.js +14 -8
  40. package/src/kernels/js/worker-entry.js +1 -23
  41. package/src/kernels/js/worker-file-helpers.js +58 -0
  42. package/src/kernels/js/worker-host.ts +24 -6
  43. package/src/kernels/js/worker-package-resolve.js +10 -2
  44. package/src/kernels/js/worker-runtime.js +5 -65
  45. package/src/kernels/js/worker-slot.ts +5 -1
  46. package/src/kernels/js/worker-startup.ts +7 -5
  47. package/src/kernels/js/worker-webview.js +17 -16
  48. package/src/kernels/py/interpose.py +765 -0
  49. package/src/kernels/py/kernel-contract.ts +4 -0
  50. package/src/kernels/py/kernel-tools-host.ts +5 -1
  51. package/src/kernels/py/kernel.ts +45 -5
  52. package/src/kernels/py/path_identity.py +162 -0
  53. package/src/kernels/py/path_identity_posix.py +74 -0
  54. package/src/kernels/py/path_identity_windows.py +93 -0
  55. package/src/kernels/py/permission-core.md +103 -0
  56. package/src/kernels/py/permission-host.ts +198 -0
  57. package/src/kernels/py/permission_bridge.py +145 -0
  58. package/src/kernels/py/prelude.py +40 -5
  59. package/src/kernels/py/transport.ts +15 -8
  60. package/src/kernels/rb/kernel.ts +3 -0
  61. package/src/kernels/shared/invocation-authority.ts +59 -0
  62. package/src/kernels/shared/kernel-tools-pump.ts +22 -1
  63. package/src/kernels/shared/path-identity.d.mts +42 -0
  64. package/src/kernels/shared/path-identity.mjs +149 -0
  65. package/src/kernels/shared/subprocess-contract.ts +2 -0
  66. package/src/kernels/shared/subprocess-kernel.ts +28 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,54 @@
1
1
  # Changelog
2
2
 
3
+ ## [2026.10.30-2] - 2026-10-30
4
+
5
+ ### Breaking Changes
6
+
7
+ ### Added
8
+
9
+ ### Changed
10
+
11
+ ### Fixed
12
+
13
+ - Group-signal notices ignore inert quoted text and shell comments without hiding commands inside executable substitutions ([#3019](https://github.com/code-yeongyu/senpi/issues/3019)).
14
+ - Keep held permission decisions parked until their two-second deadline when the host claims the shared slot before the worker waits (#4709).
15
+
16
+ ### Removed
17
+
18
+ ## [2026.10.30-1] - 2026-10-30
19
+
20
+ ### Breaking Changes
21
+
22
+ ### Added
23
+
24
+ - 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)).
25
+ - 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.
26
+ - 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.
27
+ - 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).
28
+ - 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.
29
+ - 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)).
30
+ - 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.
31
+ - 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.
32
+ - 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.
33
+ - 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)).
34
+ - Known limitation: path rules do not follow hard links; tracked in #4576.
35
+ - 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).
36
+ - Revoke Python invocation authority immediately and wait for observed interpreter retirement before admitting a replacement ([#4392](https://github.com/code-yeongyu/senpi/pull/4392)).
37
+ - 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)).
38
+ - 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)).
39
+ - 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.
40
+ - 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)).
41
+ - 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)).
42
+
43
+ ### Changed
44
+
45
+ ### Fixed
46
+
47
+ - 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))
48
+ - 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).
49
+
50
+ ### Removed
51
+
3
52
  ## [2026.10.27-2] - 2026-10-27
4
53
 
5
54
  ### 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-2",
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-2",
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-2"
42
42
  },
43
43
  "devDependencies": {
44
- "@code-yeongyu/senpi": "2026.10.27-2"
44
+ "@code-yeongyu/senpi": "2026.10.30-2"
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();
@@ -26,10 +26,286 @@ const PKILL_GROUP = new RegExp(String.raw`${COMMAND_POSITION}pkill\b[^\n;|&]*\s(
26
26
  // can include the agent's own (bun, node, senpi). Inside an `echo` string it is only text.
27
27
  const KILLALL = new RegExp(String.raw`${COMMAND_POSITION}killall\s`, "mu");
28
28
 
29
+ // senpi#3019: a keyword inside quoted text is not a command position, so the scan runs on a copy whose inert text is
30
+ // blanked: single-quoted strings, comments, and the literal parts of double-quoted strings and heredoc bodies (all of a
31
+ // body whose delimiter is quoted). A command substitution, `$(...)` or a backtick pair, runs even inside double quotes
32
+ // and unquoted heredoc bodies, so it keeps its text, as a `-c` script does; both are command lines, with their own inert
33
+ // text blanked in turn. A `<<` whose delimiter line never comes blanks nothing.
34
+ const SCRIPT_FLAG_BEFORE_QUOTE = /(?:^|\s)-[A-Za-z]*c\s+$/u;
35
+ const WORD_END = /[\s;&|<>()]/u;
36
+ const BEFORE_COMMENT = /[\s;&|()]/u;
37
+ const MAX_SCAN_WORK = 262_144;
38
+ const SCAN_LIMIT = Symbol("advisory scan limit");
39
+
40
+ function scanStep(scan, nesting, work = 1) {
41
+ scan.remaining -= work;
42
+ if (scan.remaining < 0 || nesting > 64) throw SCAN_LIMIT;
43
+ }
44
+
45
+ function blank(text) {
46
+ return text.replace(/[^\n]/gu, "x");
47
+ }
48
+
49
+ // For a `$(` or a backtick at `index`: the index of the `)` or backtick that closes it, or -1 when none does. Anything
50
+ // else: undefined.
51
+ function substitutionEnd(text, index, scan, nesting) {
52
+ if (text[index] === "`") {
53
+ const end = text.indexOf("`", index + 1);
54
+ scanStep(scan, nesting, (end === -1 ? text.length : end) - index);
55
+ return end;
56
+ }
57
+ if (text[index] !== "$" || text[index + 1] !== "(") return undefined;
58
+ let depth = 0;
59
+ const cases = [];
60
+ let commandStart = true;
61
+ for (let at = index + 1; at < text.length; at++) {
62
+ scanStep(scan, nesting);
63
+ const char = text[at];
64
+ const currentCase = cases.at(-1);
65
+ const atCaseDepth = currentCase?.depth === depth;
66
+ if (char === "\\") {
67
+ if (text[at + 1] !== "\n") {
68
+ if (atCaseDepth && currentCase.header === 2) currentCase.header = 1;
69
+ if (atCaseDepth && currentCase.pattern) currentCase.patternStarted = true;
70
+ commandStart = false;
71
+ }
72
+ at++;
73
+ } else if (char === "#" && BEFORE_COMMENT.test(text[at - 1])) {
74
+ const end = text.indexOf("\n", at);
75
+ scanStep(scan, nesting, (end === -1 ? text.length : end) - at);
76
+ if (end === -1) return -1;
77
+ at = end;
78
+ commandStart = true;
79
+ } else if (char === "'" || char === '"' || char === "`") {
80
+ if (atCaseDepth && currentCase.header === 2) currentCase.header = 1;
81
+ if (atCaseDepth && currentCase.pattern) currentCase.patternStarted = true;
82
+ at = closingQuote(text, at, scan, nesting + 1);
83
+ commandStart = false;
84
+ } else if (char === "(") {
85
+ // A case pattern may open with "(", but its mandatory ")" never closes the substitution.
86
+ if (atCaseDepth && currentCase.pattern && !currentCase.patternStarted) currentCase.patternStarted = true;
87
+ else depth++;
88
+ commandStart = !currentCase?.pattern || text[at - 1] === "$";
89
+ } else if (char === ")") {
90
+ if (atCaseDepth && currentCase.pattern) {
91
+ currentCase.pattern = false;
92
+ commandStart = true;
93
+ continue;
94
+ }
95
+ depth--;
96
+ if (depth === 0) return at;
97
+ commandStart = false;
98
+ } else if (char === ";") {
99
+ const terminator = text.startsWith(";;&", at) ? 3 : text[at + 1] === ";" || text[at + 1] === "&" ? 2 : 0;
100
+ if (atCaseDepth && currentCase.header === 0 && !currentCase.pattern && terminator > 0) {
101
+ currentCase.pattern = true;
102
+ currentCase.patternStarted = false;
103
+ at += terminator - 1;
104
+ }
105
+ commandStart = true;
106
+ } else if (char === "\n" || char === "|" || char === "&") {
107
+ commandStart = true;
108
+ } else if (!WORD_END.test(char)) {
109
+ let end = at + 1;
110
+ while (end < text.length && !WORD_END.test(text[end]) && !["'", '"', "\\", "`"].includes(text[end])) {
111
+ scanStep(scan, nesting);
112
+ end++;
113
+ }
114
+ const word = text.slice(at, end);
115
+ if (atCaseDepth && currentCase.header === 2) {
116
+ currentCase.header = 1;
117
+ commandStart = false;
118
+ } else if (atCaseDepth && currentCase.header === 1 && word === "in") {
119
+ currentCase.header = 0;
120
+ currentCase.pattern = true;
121
+ commandStart = false;
122
+ } else if (atCaseDepth && currentCase.pattern) {
123
+ if (word === "esac" && !currentCase.patternStarted) cases.pop();
124
+ else currentCase.patternStarted = true;
125
+ commandStart = false;
126
+ } else if (word === "case" && commandStart) {
127
+ cases.push({ depth, header: 2, pattern: false, patternStarted: false });
128
+ commandStart = false;
129
+ } else if (word === "esac" && commandStart && atCaseDepth) {
130
+ cases.pop();
131
+ commandStart = false;
132
+ } else {
133
+ commandStart = commandStart && /^(?:if|then|do|else|elif|while|until|!|\{)$/u.test(word);
134
+ }
135
+ at = end - 1;
136
+ }
137
+ }
138
+ return -1;
139
+ }
140
+
141
+ // The index of the quote that closes the one at `open`, or the text length. A substitution inside double quotes is a
142
+ // context of its own, so a quote inside it does not close the string.
143
+ function closingQuote(text, open, scan, nesting) {
144
+ const quote = text[open];
145
+ let backslashes = 0;
146
+ if (quote === "'" && text[open - 1] === "$") {
147
+ for (let at = open - 2; text[at] === "\\"; at--) {
148
+ scanStep(scan, nesting);
149
+ backslashes++;
150
+ }
151
+ }
152
+ const ansi = quote === "'" && text[open - 1] === "$" && backslashes % 2 === 0;
153
+ for (let index = open + 1; index < text.length; index++) {
154
+ scanStep(scan, nesting);
155
+ if (text[index] === quote) return index;
156
+ if (ansi && text[index] === "\\") {
157
+ index++;
158
+ continue;
159
+ }
160
+ if (quote === "'") continue;
161
+ const end = substitutionEnd(text, index, scan, nesting + 1);
162
+ if (end === -1) return text.length;
163
+ if (end !== undefined) index = end;
164
+ else if (text[index] === "\\") index++;
165
+ }
166
+ return text.length;
167
+ }
168
+
169
+ function blankLiteralText(text, scan, nesting) {
170
+ let out = "";
171
+ let index = 0;
172
+ while (index < text.length) {
173
+ scanStep(scan, nesting);
174
+ const end = substitutionEnd(text, index, scan, nesting + 1);
175
+ if (end !== undefined && end !== -1) {
176
+ const open = text[index] === "`" ? 1 : 2;
177
+ out += text.slice(index, index + open) + blankQuotedText(text.slice(index + open, end), scan, nesting + 1) + text[end];
178
+ index = end + 1;
179
+ continue;
180
+ }
181
+ const step = text[index] === "\\" ? 2 : 1;
182
+ out += blank(text.slice(index, index + step));
183
+ index += step;
184
+ }
185
+ return out;
186
+ }
187
+
188
+ // `<<` or `<<-`, optional blanks, then the delimiter word up to an unquoted blank or operator. Quote removal gives the
189
+ // delimiter, and any quoting in the word leaves the body unexpanded.
190
+ function readHeredoc(text, at, scan, nesting) {
191
+ let index = at + 2;
192
+ const stripTabs = text[index] === "-";
193
+ if (stripTabs) index++;
194
+ while (text[index] === " " || text[index] === "\t") {
195
+ scanStep(scan, nesting);
196
+ index++;
197
+ }
198
+ let delimiter = "";
199
+ let quoted = false;
200
+ while (index < text.length && !WORD_END.test(text[index])) {
201
+ scanStep(scan, nesting);
202
+ const char = text[index];
203
+ if (char === "'" || char === '"') {
204
+ const end = text.indexOf(char, index + 1);
205
+ scanStep(scan, nesting, (end === -1 ? text.length : end) - index);
206
+ if (end === -1) return undefined;
207
+ delimiter += text.slice(index + 1, end);
208
+ quoted = true;
209
+ index = end + 1;
210
+ } else if (char === "\\") {
211
+ delimiter += text[index + 1] ?? "";
212
+ quoted = true;
213
+ index += 2;
214
+ } else {
215
+ delimiter += char;
216
+ index++;
217
+ }
218
+ }
219
+ return delimiter ? { delimiter, quoted, stripTabs, end: index } : undefined;
220
+ }
221
+
222
+ function closingLine(text, from, { delimiter, stripTabs }, scan, nesting) {
223
+ let start = from;
224
+ while (start < text.length) {
225
+ const newline = text.indexOf("\n", start);
226
+ const end = newline === -1 ? text.length : newline;
227
+ scanStep(scan, nesting, end - start + 1);
228
+ const line = text.slice(start, end);
229
+ if ((stripTabs ? line.replace(/^\t+/u, "") : line) === delimiter) return { start, end };
230
+ start = end + 1;
231
+ }
232
+ return undefined;
233
+ }
234
+
235
+ function blankQuotedText(text, scan, nesting) {
236
+ let out = "";
237
+ const heredocs = [];
238
+ let index = 0;
239
+ while (index < text.length) {
240
+ scanStep(scan, nesting);
241
+ const char = text[index];
242
+ if (char === "\\") {
243
+ out += text.slice(index, index + 2);
244
+ index += 2;
245
+ continue;
246
+ }
247
+ if (char === "'" || char === '"') {
248
+ const end = closingQuote(text, index, scan, nesting + 1);
249
+ const inner = text.slice(index + 1, end);
250
+ scanStep(scan, nesting, out.length);
251
+ if (SCRIPT_FLAG_BEFORE_QUOTE.test(out)) out += char + blankQuotedText(inner, scan, nesting + 1);
252
+ else out += char + (char === "'" ? blank(inner) : blankLiteralText(inner, scan, nesting + 1));
253
+ if (end < text.length) out += char;
254
+ index = end + 1;
255
+ continue;
256
+ }
257
+ if (char === "#" && (index === 0 || BEFORE_COMMENT.test(text[index - 1]))) {
258
+ const newline = text.indexOf("\n", index);
259
+ const end = newline === -1 ? text.length : newline;
260
+ scanStep(scan, nesting, end - index);
261
+ out += blank(text.slice(index, end));
262
+ index = end;
263
+ continue;
264
+ }
265
+ if (text.startsWith("<<<", index)) {
266
+ out += "<<<";
267
+ index += 3;
268
+ continue;
269
+ }
270
+ const heredoc = text.startsWith("<<", index) ? readHeredoc(text, index, scan, nesting) : undefined;
271
+ if (heredoc) {
272
+ heredocs.push(heredoc);
273
+ out += text.slice(index, heredoc.end);
274
+ index = heredoc.end;
275
+ continue;
276
+ }
277
+ out += char;
278
+ index++;
279
+ if (char !== "\n") continue;
280
+ // Each body starts on the line after its `<<` and ends at a line holding only its delimiter.
281
+ for (const pending of heredocs.splice(0)) {
282
+ const close = closingLine(text, index, pending, scan, nesting);
283
+ if (!close) break;
284
+ const body = text.slice(index, close.start);
285
+ out += (pending.quoted ? blank(body) : blankLiteralText(body, scan, nesting + 1)) + text.slice(close.start, close.end);
286
+ index = close.end;
287
+ if (text[index] === "\n") {
288
+ out += "\n";
289
+ index++;
290
+ }
291
+ }
292
+ }
293
+ return out;
294
+ }
295
+
29
296
  export const GROUP_SIGNAL_NOTICE_TAG = "[senpi:group-signal]";
30
297
 
31
298
  export function signalsProcessGroup(commandText) {
32
- return KILL_GROUP.test(commandText) || PKILL_GROUP.test(commandText) || KILLALL.test(commandText);
299
+ // Bound quote masking, but keep a conservative raw-text notice when the scan cannot finish.
300
+ let text = commandText;
301
+ if (commandText.length <= MAX_SCAN_WORK) {
302
+ try {
303
+ text = blankQuotedText(commandText, { remaining: MAX_SCAN_WORK }, 0);
304
+ } catch (error) {
305
+ if (error !== SCAN_LIMIT) throw error;
306
+ }
307
+ }
308
+ return KILL_GROUP.test(text) || PKILL_GROUP.test(text) || KILLALL.test(text);
33
309
  }
34
310
 
35
311
  export function groupSignalNotice(api, readGroup = agentProcessGroup) {