@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
@@ -0,0 +1,97 @@
1
+ import { replaceMethod, requestedPath } from "./interpose-paths.js";
2
+
3
+ const { basename } = process.getBuiltinModule("node:path");
4
+ // Bun gives an explicit stdio[i] priority over the matching named option, so both are decided rather
5
+ // than one standing in for the other. The first three slots carry one direction each; a slot at
6
+ // index 3 or above has no named option and is handed to the child as a descriptor it can both read
7
+ // and write.
8
+ const SLOT_MODES = [["read"], ["write"], ["write"]];
9
+ const EXTRA_SLOT_MODES = ["read", "write"];
10
+ const quote = (value) => `'${String(value).replaceAll("'", "'\\''")}'`;
11
+ const cwdOf = (value) => requestedPath(value) ?? process.cwd();
12
+
13
+ export function installProcessPermissions(check, files) {
14
+ const cp = process.getBuiltinModule("node:child_process");
15
+ for (const name of ["spawn", "spawnSync", "exec", "execSync", "execFile", "execFileSync", "fork"]) {
16
+ replaceMethod(cp, name, (original) => function (...parameters) {
17
+ check(() => {
18
+ const [file, args, options] = parameters;
19
+ const shellText = name === "exec" || name === "execSync";
20
+ const argv = Array.isArray(args) ? args : [];
21
+ const opts = shellText ? args : Array.isArray(args) || args == null ? options : args;
22
+ const shell = opts?.shell;
23
+ const command = name === "fork"
24
+ ? [opts?.execPath ?? process.execPath, ...(opts?.execArgv ?? process.execArgv), file, ...argv].map(quote).join(" ")
25
+ : shellText ? file : shell ? [file, ...argv].join(" ") : [file, ...argv].map(quote).join(" ");
26
+ return {
27
+ call: { tool: "bash", input: { command }, cwd: cwdOf(opts?.cwd) },
28
+ unsupported: typeof shell === "string" && !/^(?:sh|bash|zsh|dash)$/u.test(basename(shell)),
29
+ };
30
+ });
31
+ return original.apply(this, parameters);
32
+ });
33
+ }
34
+ const bun = globalThis.Bun;
35
+ if (!bun) return;
36
+ for (const name of ["spawn", "spawnSync"]) replaceMethod(bun, name, (original) => function (...parameters) {
37
+ const [first, second] = parameters;
38
+ const options = Array.isArray(first) ? second : first;
39
+ check(() => {
40
+ const argv = Array.isArray(first) ? first : first?.cmd;
41
+ return {
42
+ call: { tool: "bash", input: { command: Array.isArray(argv) ? argv.map(quote).join(" ") : "" }, cwd: cwdOf(options?.cwd) },
43
+ unsupported: !Array.isArray(argv),
44
+ };
45
+ });
46
+ const slots = Array.isArray(options?.stdio) ? options.stdio : [];
47
+ files.source(options?.stdin); // sink:spawn-stdin
48
+ files.handle(options?.stdout, "write"); // sink:spawn-stdout
49
+ files.handle(options?.stderr, "write"); // sink:spawn-stderr
50
+ for (const [index, slot] of slots.entries()) {
51
+ for (const mode of SLOT_MODES[index] ?? EXTRA_SLOT_MODES) {
52
+ if (mode === "read") files.source(slot); // sink:spawn-stdio-read
53
+ if (mode === "write") files.handle(slot, "write"); // sink:spawn-stdio-write
54
+ }
55
+ }
56
+ return original.apply(this, parameters);
57
+ });
58
+ const shell = bun.$;
59
+ if (typeof shell !== "function") return;
60
+ let shellCwd;
61
+ const render = (value) => {
62
+ if (Array.isArray(value)) return value.map(render).join(" ");
63
+ if (value && typeof value === "object") {
64
+ if (typeof value.raw === "string") return value.raw;
65
+ check(() => ({ unsupported: true }));
66
+ }
67
+ return shell.escape(String(value));
68
+ };
69
+ const guarded = function (strings, ...values) {
70
+ check(() => ({
71
+ call: {
72
+ tool: "bash",
73
+ input: { command: strings.map((part, index) => part + (index < values.length ? render(values[index]) : "")).join("") },
74
+ cwd: cwdOf(shellCwd),
75
+ },
76
+ }));
77
+ return shell(strings, ...values);
78
+ };
79
+ for (const key of Reflect.ownKeys(shell)) {
80
+ if (key !== "Shell" && !Object.hasOwn(guarded, key)) Object.defineProperty(guarded, key, Object.getOwnPropertyDescriptor(shell, key));
81
+ }
82
+ for (const name of ["cwd", "env", "nothrow", "throws"]) {
83
+ if (typeof shell[name] !== "function") continue;
84
+ guarded[name] = (...args) => {
85
+ if (name === "cwd") shellCwd = args[0];
86
+ shell[name](...args);
87
+ return guarded;
88
+ };
89
+ }
90
+ if (shell.Shell) guarded.Shell = new Proxy(shell.Shell, {
91
+ construct(target, args, newTarget) {
92
+ check(() => ({ unsupported: true }));
93
+ return Reflect.construct(target, args, newTarget);
94
+ },
95
+ });
96
+ bun.$ = guarded;
97
+ }
@@ -0,0 +1,59 @@
1
+ import { checkPath, replaceMethod } from "./interpose-paths.js";
2
+
3
+ const { constants } = process.getBuiltinModule("node:fs");
4
+
5
+ function openModes(flags = "r") {
6
+ flags ??= "r";
7
+ if (typeof flags === "number") {
8
+ const access = flags & (constants.O_RDONLY | constants.O_WRONLY | constants.O_RDWR);
9
+ if (access === constants.O_WRONLY) return ["write"];
10
+ return access === constants.O_RDWR || (flags & (constants.O_CREAT | constants.O_TRUNC)) !== 0 ? ["read", "write"] : ["read"];
11
+ }
12
+ return flags.includes("+") ? ["read", "write"] : /^[wax]/u.test(flags) ? ["write"] : ["read"];
13
+ }
14
+
15
+ export function createResourceTable(check) {
16
+ const descriptors = new Map();
17
+ const objects = new WeakMap();
18
+ const table = (resource) => typeof resource === "number" ? descriptors : objects;
19
+ const record = (resource, path, modes) => {
20
+ if (typeof resource === "number" || (resource !== null && typeof resource === "object")) {
21
+ table(resource).set(resource, { path, modes });
22
+ }
23
+ return resource;
24
+ };
25
+ const forget = (resource) => table(resource).delete(resource);
26
+ const use = (resource, mode) => {
27
+ check(() => {
28
+ const grant = table(resource).get(resource);
29
+ return { granted: true, unsupported: !grant || (mode !== undefined && !grant.modes.includes(mode)) };
30
+ });
31
+ };
32
+ const path = (resource) => table(resource).get(resource)?.path;
33
+ const open = (file, flags) => {
34
+ const modes = openModes(flags);
35
+ for (const mode of modes) checkPath(check, mode === "read" ? "read" : "write", file);
36
+ return modes;
37
+ };
38
+ const handle = (value, file, modes) => {
39
+ record(value, file, modes);
40
+ record(value.fd, file, modes);
41
+ for (const [mode, names] of [
42
+ ["read", ["read", "readv", "readFile", "readLines", "createReadStream", "readableWebStream"]],
43
+ ["write", ["write", "writev", "writeFile", "appendFile", "truncate", "chmod", "chown", "utimes", "sync", "datasync", "createWriteStream"]],
44
+ ]) {
45
+ for (const name of names) replaceMethod(value, name, (original) => function (...args) {
46
+ use(this, mode);
47
+ const result = original.apply(this, args);
48
+ if (/Stream$/u.test(name)) record(result, file, [mode]);
49
+ return result;
50
+ });
51
+ }
52
+ replaceMethod(value, "close", (original) => function (...args) {
53
+ const fd = this.fd;
54
+ return original.apply(this, args).then((result) => { forget(fd); forget(this); return result; });
55
+ });
56
+ return value;
57
+ };
58
+ return { record, forget, use, path, open, handle };
59
+ }
@@ -0,0 +1,104 @@
1
+ import { checkPath, executionPath, replaceMethod } from "./interpose-paths.js";
2
+
3
+ // Skip quoted data/identifiers and comments, retaining semicolons to examine every statement.
4
+ function sqlWords(sql) {
5
+ return String(sql).replace(/--[^\n]*|\/\*[\s\S]*?\*\/|'(?:''|[^'])*'|"(?:""|[^"])*"|`(?:``|[^`])*`|\[[^\]]*\]/gu, " ")
6
+ .toUpperCase().match(/[A-Z_]+|;/gu) ?? [];
7
+ }
8
+
9
+ function reachesOtherFile(sql) {
10
+ let first;
11
+ for (const word of sqlWords(sql)) {
12
+ if (word === ";") { first = undefined; continue; }
13
+ first ??= word;
14
+ if (first === "ATTACH" || (first === "VACUUM" && word === "INTO")) return true;
15
+ }
16
+ return false;
17
+ }
18
+
19
+ export function installSqlitePermissions(check) {
20
+ const paths = new WeakMap();
21
+ const memory = (path) => path === ":memory:" || path === "";
22
+ const file = (path, mode) => { if (!memory(path)) checkPath(check, mode, path); };
23
+ const refuse = () => check(() => ({ unsupported: true }));
24
+ const guardSql = (sql) => check(() => ({ granted: true, unsupported: reachesOtherFile(sql) }));
25
+ const install = (sqlite, name, node) => {
26
+ const Original = sqlite[name];
27
+ if (!Original) return;
28
+ const readonly = (options) => node ? options?.readOnly === true
29
+ : typeof options === "number" ? (options & sqlite.constants.SQLITE_OPEN_READONLY) !== 0 : options?.readonly === true;
30
+ const opening = (path, options) => {
31
+ if (node && options?.allowExtension) refuse();
32
+ file(path, "read");
33
+ if (!readonly(options)) file(path, "write");
34
+ };
35
+ const proto = Original.prototype;
36
+ const statement = (node ? sqlite.StatementSync : sqlite.Statement)?.prototype;
37
+ for (const method of ["run", "get", "all", "iterate", "values"]) replaceMethod(statement, method, (original) => function (...args) {
38
+ check(() => ({ granted: true }));
39
+ const result = original.apply(this, args);
40
+ if (method === "iterate") replaceMethod(result, "next", (next) => function (...parameters) {
41
+ check(() => ({ granted: true }));
42
+ return next.apply(this, parameters);
43
+ });
44
+ return result;
45
+ });
46
+ if (node) {
47
+ const setAuthorizer = proto.setAuthorizer;
48
+ replaceMethod(proto, "setAuthorizer", () => function (callback) {
49
+ return setAuthorizer.call(this, (operation, ...args) => {
50
+ if (operation === sqlite.constants.SQLITE_ATTACH) refuse();
51
+ return callback?.(operation, ...args) ?? sqlite.constants.SQLITE_OK;
52
+ });
53
+ });
54
+ }
55
+ for (const method of ["exec", "run", "prepare", "query"]) replaceMethod(proto, method, (original) => function (sql, ...args) {
56
+ // Also guards runtimes which translate SQLite authorizer throws to SQLITE_AUTH.
57
+ guardSql(sql);
58
+ return original.call(this, sql, ...args);
59
+ });
60
+ replaceMethod(proto, "enableLoadExtension", (original) => function (enabled) {
61
+ if (enabled) refuse();
62
+ return original.call(this, enabled);
63
+ });
64
+ replaceMethod(proto, "loadExtension", (original) => function (...args) {
65
+ refuse();
66
+ return original.apply(this, args);
67
+ });
68
+ sqlite[name] = new Proxy(Original, {
69
+ construct(target, args, newTarget) {
70
+ const [path = ":memory:", options] = args;
71
+ opening(path, options);
72
+ const actual = memory(path) ? path : executionPath(path);
73
+ const db = Reflect.construct(target, [actual, ...args.slice(1)], newTarget);
74
+ paths.set(db, path);
75
+ if (node && typeof db.setAuthorizer === "function") db.setAuthorizer(null);
76
+ return db;
77
+ },
78
+ });
79
+ if (sqlite.default === Original) sqlite.default = sqlite[name];
80
+ if (!node) {
81
+ replaceMethod(Original, "open", (original) => function (path, options) {
82
+ opening(path, options);
83
+ const db = original.call(this, memory(path) ? path : executionPath(path), options);
84
+ paths.set(db, path);
85
+ return db;
86
+ });
87
+ replaceMethod(Original, "setCustomSQLite", (original) => function (...args) {
88
+ refuse();
89
+ return original.apply(this, args);
90
+ });
91
+ }
92
+ if (node) replaceMethod(sqlite, "backup", (original) => function (db, dest, ...args) {
93
+ file(paths.get(db), "read");
94
+ file(dest, "write");
95
+ return original.call(this, db, executionPath(dest), ...args);
96
+ });
97
+ };
98
+ const nodeSqlite = process.getBuiltinModule("node:sqlite");
99
+ install(nodeSqlite, "DatabaseSync", true);
100
+ if (globalThis.Bun) {
101
+ const require = process.getBuiltinModule("node:module").createRequire(import.meta.url);
102
+ install(require("bun:sqlite"), "Database", false);
103
+ }
104
+ }
@@ -0,0 +1,86 @@
1
+ // This module must be the first import in every worker. No builtin ESM namespace exists yet.
2
+ import { installFilesystemPermissions } from "./interpose-fs.js";
3
+ import { installProcessPermissions } from "./interpose-process.js";
4
+ import { installBunPermissions } from "./interpose-bun.js";
5
+ import { installSqlitePermissions } from "./interpose-sqlite.js";
6
+ import { installModulePermissions } from "./interpose-modules.js";
7
+ import { installCapabilityPermissions } from "./interpose-capabilities.js";
8
+ import { checkPath } from "./interpose-paths.js";
9
+
10
+ // Bootstrap is engine-owned; there is no user statement until this one-time handoff finishes.
11
+ let check = () => {};
12
+ let engineDepth = 0;
13
+ const authorize = (operation) => { if (engineDepth === 0) return check(operation); };
14
+ const fs = process.getBuiltinModule("node:fs");
15
+ const workerThreads = process.getBuiltinModule("node:worker_threads");
16
+ const bootstrap = workerThreads.workerData;
17
+ // Restore the public value before any builtin namespace or user preload observes it.
18
+ if (bootstrap.script) workerThreads.workerData = bootstrap.userWorkerData;
19
+ // Bun gives each worker one ESM namespace per builtin, copied from the module object at its first
20
+ // import and never refreshed, so the guarded constructor must be in place before that import. Only a
21
+ // user worker started under an active token is enforced against for its whole life, so only it gets
22
+ // the forwarder; every other context keeps the engine's own Worker on the module.
23
+ let guardedWorker;
24
+ let forwarder;
25
+ if (bootstrap.script && bootstrap.enforced) {
26
+ // A function, not a class: it hands back the instance the guarded class builds for new.target.
27
+ forwarder = function Worker(...args) {
28
+ if (!new.target) throw new TypeError("Class constructor Worker cannot be invoked without 'new'");
29
+ if (!guardedWorker) throw new Error("Worker is unavailable until the permission runtime starts");
30
+ return Reflect.construct(guardedWorker, args, new.target);
31
+ };
32
+ Object.setPrototypeOf(forwarder.prototype, EventTarget.prototype);
33
+ Object.defineProperty(workerThreads, "Worker", { value: forwarder, writable: false, configurable: false, enumerable: true });
34
+ }
35
+ const installGuardedWorker = (Guarded) => {
36
+ if (!forwarder) {
37
+ globalThis.Worker = Guarded;
38
+ return;
39
+ }
40
+ guardedWorker = Guarded;
41
+ Object.setPrototypeOf(forwarder.prototype, Guarded.prototype);
42
+ globalThis.Worker = forwarder;
43
+ };
44
+ const originals = { ...fs.promises, readFileSync: fs.readFileSync, realpathSync: fs.realpathSync };
45
+ const execFile = process.getBuiltinModule("node:child_process").execFile;
46
+ const cleanupExecFile = (...args) => {
47
+ engineDepth++;
48
+ try { return execFile(...args); }
49
+ finally { engineDepth--; }
50
+ };
51
+ const { dirname } = process.getBuiltinModule("node:path");
52
+ const permissionIO = {
53
+ read(raw, actual) { checkPath(authorize, "read", raw, { executed: actual }); return originals.readFile(actual, "utf8"); },
54
+ async write(raw, actual, data) {
55
+ checkPath(authorize, "write", raw, { executed: actual });
56
+ await originals.mkdir(dirname(actual), { recursive: true });
57
+ await originals.writeFile(actual, data);
58
+ },
59
+ };
60
+ const resources = installFilesystemPermissions(authorize);
61
+ installProcessPermissions(authorize, installBunPermissions(authorize, resources));
62
+ installSqlitePermissions(authorize);
63
+ const sealModules = installModulePermissions(authorize, Boolean(bootstrap.script));
64
+
65
+ // The sole bootstrap handoff is consumed before user code. Neither module exports the originals.
66
+ const { initializePermissionRuntime } = await import("./permission-runtime.js");
67
+ let runtimeAuthorize;
68
+ let activated = false;
69
+ const activate = () => {
70
+ if (activated) return;
71
+ activated = true;
72
+ check = runtimeAuthorize;
73
+ sealModules();
74
+ installCapabilityPermissions(authorize);
75
+ };
76
+ // The worker bootstrap and the engine hooks are separate inputs: the bootstrap carries the
77
+ // permission transport this worker must consume and delete, the engine carries the authorized IO
78
+ // the runtime hands to the cell prelude. Collapsing them into one argument drops one of the two.
79
+ await initializePermissionRuntime((authorize) => { runtimeAuthorize = authorize; }, bootstrap, {
80
+ permissionIO,
81
+ resolverIO: originals,
82
+ cleanupExecFile,
83
+ activate,
84
+ installGuardedWorker,
85
+ });
86
+ activate();
@@ -2,7 +2,31 @@ import type { KernelMemoryThresholds } from "../../bridge/memory-protocol.ts";
2
2
  import type { KernelToHostMessage } from "../../bridge/protocol.ts";
3
3
  import type { EvalKernelRunInput } from "../../tool/types.ts";
4
4
  import type { SessionEnvironment } from "../session-env.ts";
5
+ import type { InvocationPermissions } from "../shared/invocation-authority.ts";
5
6
  import type { JavaScriptInterruptBounds } from "./interrupt-bounds.ts";
7
+ import type { CellDecision, CellOperation, CellRule } from "./permission-slots.mjs";
8
+
9
+ export interface CellAuthorityToken {
10
+ readonly id: string;
11
+ readonly sessionId: string;
12
+ readonly cwd: string;
13
+ readonly authority: SharedArrayBuffer;
14
+ readonly rules: readonly CellRule[];
15
+ readonly signal?: AbortSignal;
16
+ }
17
+
18
+ export type CellDecider = (token: CellAuthorityToken, call: CellOperation) => CellDecision | Promise<CellDecision>;
19
+
20
+ export interface CellPermissionState {
21
+ readonly epoch: number;
22
+ readonly active: boolean;
23
+ readonly rules: readonly CellRule[];
24
+ }
25
+
26
+ export interface RevocationFailedEvent {
27
+ readonly sessionId: string;
28
+ readonly kernelId: string;
29
+ }
6
30
 
7
31
  export type ResultMessage = Extract<KernelToHostMessage, { type: "result" }>;
8
32
  export type ToolCallMessage = Extract<KernelToHostMessage, { type: "tool-call" }>;
@@ -20,6 +44,13 @@ export function resolveKernelToolNameSource(names?: KernelToolNameSource): strin
20
44
  export interface JavaScriptKernelOptions {
21
45
  readonly sessionId: string;
22
46
  readonly cwd: string;
47
+ readonly permissions?: InvocationPermissions & {
48
+ readonly decide: CellDecider;
49
+ /** Slot wait deadline in ms; production keeps the 2s default. Integration suites raise it above host load. */
50
+ readonly checkDeadlineMs?: number;
51
+ };
52
+ /** Integration marks direct tool admission DEAD too; the kernel host fences cells itself. */
53
+ readonly onRevocationFailed?: (event: RevocationFailedEvent) => void;
23
54
  /**
24
55
  * Collects the exit status of a retired worker's children once they are killed (#1962): no thread is left to
25
56
  * wait on them. Absent, they stay zombies until the host's child reaper or process exit collects them.
@@ -1,7 +1,7 @@
1
1
  import { kernelToolCallContext } from "./kernel-tools-context.js";
2
2
  import { kernelToolError } from "./kernel-tools-errors.js";
3
3
 
4
- export function createKernelToolPump({ getRuntime, emit, nestedInvokes }) {
4
+ export function createKernelToolPump({ getRuntime, emit, nestedInvokes, runAuthority }) {
5
5
  async function describe(message) {
6
6
  try {
7
7
  const runtime = getRuntime();
@@ -20,7 +20,7 @@ export function createKernelToolPump({ getRuntime, emit, nestedInvokes }) {
20
20
  try {
21
21
  const runtime = getRuntime();
22
22
  if (!runtime?.kernelTools) throw kernelToolError("tools_unavailable", "JS runtime not initialized");
23
- const value = await kernelToolCallContext.run(
23
+ const execute = () => kernelToolCallContext.run(
24
24
  // The scope lives only in this store: it is gone once the call settles, and it never
25
25
  // reaches the top-level cell context or the next invocation (#1731).
26
26
  {
@@ -42,6 +42,7 @@ export function createKernelToolPump({ getRuntime, emit, nestedInvokes }) {
42
42
  controller.signal,
43
43
  ),
44
44
  );
45
+ const value = await (runAuthority ? runAuthority(message.authority, execute) : execute());
45
46
  emit({ type: "kernel-tool-invoke-reply", requestId: message.requestId, ok: true, value });
46
47
  } catch (error) {
47
48
  emit({ type: "kernel-tool-invoke-reply", requestId: message.requestId, ok: false, error: pumpError(error) });
@@ -100,6 +101,7 @@ function pumpError(error) {
100
101
  stack: error.stack,
101
102
  ...(typeof error.code === "string" ? { code: error.code } : {}),
102
103
  ...(isPlainRecord(error.details) ? { details: error.details } : {}),
104
+ ...(error.code === "cell_permission_denied" && error.rule ? { details: { permissionRule: error.rule } } : {}),
103
105
  };
104
106
  }
105
107
  return { message: String(error) };
@@ -11,7 +11,7 @@ import {
11
11
  } from "../../bridge/reserved.ts";
12
12
  import { type KernelPreludePlan, KernelPreludeTracker } from "../shared/kernel-prelude-plan.ts";
13
13
  import type { JavaScriptKernelOptions as BaseJavaScriptKernelOptions } from "./kernel-contract.ts";
14
- import { rewriteImports } from "./rewrite-imports.ts";
14
+ import { hasDynamicCodeCapability, rewriteImports } from "./rewrite-imports.ts";
15
15
 
16
16
  const PREPARED_CELL_PREFIX = "/*senpi:prepared-cell*/";
17
17
 
@@ -77,8 +77,10 @@ function loaderPrelude(context: RuntimeModuleContext): string {
77
77
  "globalThis.__senpi_import__ = async (source, options) => {",
78
78
  " const context = globalThis.__senpi_module_context__;",
79
79
  " const specifier = String(source);",
80
+ " const permissionImport = globalThis[Symbol.for('senpi.kernel.permissionImport')];",
81
+ " permissionImport?.(specifier, new URL('__cell__.js', context.cwdUrl).href);",
80
82
  " const kernelBun = globalThis[Symbol.for('senpi.kernel.bun')];",
81
- " if (specifier === 'bun' && kernelBun) return { ...(await import('bun')), WebView: kernelBun.WebView, default: kernelBun };",
83
+ " if (specifier === 'bun' && kernelBun) return { ...kernelBun, default: kernelBun };",
82
84
  // Under Bun a cell's named import (`import { spawn } from 'node:child_process'`) binds the builtin's original
83
85
  // export even after the worker patched the module object (worker-cwd.js), so the session cwd and the cell
84
86
  // children's own process group (senpi#2995) were skipped. The patched module objects are served instead.
@@ -106,7 +108,10 @@ function loaderPrelude(context: RuntimeModuleContext): string {
106
108
  " const urlModule = await import('node:url');",
107
109
  " target = urlModule.pathToFileURL(specifier).href;",
108
110
  " }",
109
- " const load = (resolved) => (options === undefined ? import(resolved) : import(resolved, options));",
111
+ // The import and everything it loads (resolution hooks, module bodies) run under the cell's loading window.
112
+ " const permissionLoad = globalThis[Symbol.for('senpi.kernel.permissionLoad')];",
113
+ " const run = (resolved) => (options === undefined ? import(resolved) : import(resolved, options));",
114
+ " const load = (resolved) => (permissionLoad ? permissionLoad(() => run(resolved)) : run(resolved));",
110
115
  " const resolvePackage = globalThis[Symbol.for('senpi.kernel.resolvePackage')];",
111
116
  " if (target === specifier && typeof resolvePackage === 'function') {",
112
117
  " const resolved = resolvePackage(specifier, [context.cwdUrl, context.packageRootUrl]);",
@@ -147,6 +152,6 @@ export class LocalModuleLoader {
147
152
  const cwdUrl = sourceFile === undefined ? this.#cwdUrl : directoryUrl(dirname(sourceFile));
148
153
  const base = `\nglobalThis.__senpi_module_context__ = { ...globalThis.__senpi_module_context__, cwdUrl: ${JSON.stringify(cwdUrl)}, packageRootUrl: ${JSON.stringify(packageRoot === undefined ? null : directoryUrl(packageRoot))} };`;
149
154
  const prelude = `${this.#prelude}${base}\n${contributionPrelude(this.#contributions.plan(contributions))}`;
150
- return `${PREPARED_CELL_PREFIX}${JSON.stringify({ prelude, code: rewriteImports(code), ...(sourceFile === undefined ? {} : { sourceFile }) })}`;
155
+ return `${PREPARED_CELL_PREFIX}${JSON.stringify({ prelude, code: rewriteImports(code), dynamicCodeCapability: hasDynamicCodeCapability(code), ...(sourceFile === undefined ? {} : { sourceFile }) })}`;
151
156
  }
152
157
  }
@@ -0,0 +1,10 @@
1
+ export interface WireAuthority {
2
+ readonly id: string;
3
+ readonly authority: SharedArrayBuffer;
4
+ readonly rules: readonly import("./permission-slots.mjs").CellRule[];
5
+ }
6
+ export function authorityDecision(
7
+ current: WireAuthority | undefined,
8
+ bound: readonly WireAuthority[],
9
+ kernel: WireAuthority,
10
+ ): "pass" | "check" | "refuse";
@@ -0,0 +1,11 @@
1
+ export function authorityDecision(current, bound, kernel) {
2
+ if (!(Atomics.load(new Int32Array(kernel.authority), 0) & 2)) return "refuse";
3
+ if (current) {
4
+ const word = Atomics.load(new Int32Array(current.authority), 0);
5
+ if (!(word & 2)) return "refuse";
6
+ return word & 1 ? "check" : "pass";
7
+ }
8
+ const authorities = bound.length ? bound : [kernel];
9
+ return authorities.every((token) => (Atomics.load(new Int32Array(token.authority), 0) & 3) === 2)
10
+ ? "pass" : "refuse";
11
+ }
@@ -0,0 +1,114 @@
1
+ # JS kernel permission core
2
+
3
+ This is the core portion of senpi#4309. It provides the transport and lifetime
4
+ contract for a host `CellDecider`; it does not connect the permission extension.
5
+
6
+ ## Host integration
7
+
8
+ `JavaScriptKernelOptions.permissions` supplies a current epoch snapshot and
9
+ `decide(token, call)`. Tokens identify the invoking session and execution cwd.
10
+ The decision is either `pass` or `deny` with a rule's permission, pattern and
11
+ layer. The core does not interpret rule syntax or implement allow/ask precedence.
12
+
13
+ The host calls `revokeSessionKernels(sessionId)` when effective enforcement
14
+ changes. The call kills every authority word synchronously, interrupts parked
15
+ checks and waits for retirement. New cells wait for this completion. An
16
+ abandoned retirement permanently fences cell admission for the session and
17
+ emits `onRevocationFailed({ sessionId, kernelId })`.
18
+
19
+ The integration PR must connect that event to direct-tool admission. Direct
20
+ tools are not fenced by this core alone.
21
+
22
+ The coding-agent dispatcher stamps `options.invoker.sessionId` from its own
23
+ SessionManager. Neither cell data nor tool arguments can override that identity.
24
+ Registered extension event handlers also bind their own host context, rather
25
+ than inheriting a parent task tool's identity when a child emits an event.
26
+ The shared host pump forwards these options to `resolveInvocationToken` before
27
+ posting an invocation frame. Absent or owner identity mints an owner token;
28
+ another session is refused before the body runs if either session enforces,
29
+ with `reason: "cross_session_authority"` and the enforcing session identified.
30
+ The attached `rule` names a deny rule activating that session's enforcement;
31
+ it does not claim that this rule matched an operation inside the unexecuted
32
+ tool body. Otherwise the token identifies the invoker.
33
+ Python applies the same cross-session check before posting to the interpreter.
34
+
35
+ Live kernel owners publish their current permission snapshots to the host-only
36
+ invocation registry. The permission integration supplies `permissions.resolveSession`
37
+ for sessions without a kernel, returning that session's current state and cwd.
38
+ Without integration or a registered kernel, a session has no enforcing state in
39
+ this core and retains the existing inactive behavior. No invoker identity or
40
+ session resolver is accepted by the worker protocol. An active JS invocation
41
+ also binds its destination kernel to the invoker's revocation lifecycle. A
42
+ settled inactive invocation keeps that binding until the worker retires, so
43
+ the invoker's revocation still reaches the continuations it left behind.
44
+
45
+ ## Runtime invariants
46
+
47
+ Both worker entries load `interpose.js` first. It captures and patches builtins
48
+ before importing the runtime, including before a package can obtain a static
49
+ builtin binding. Originals stay in module-local closures. The one-time runtime
50
+ initializer does not export a token store or original functions.
51
+
52
+ Operation families are installed by `interpose-fs.js`, `interpose-process.js`,
53
+ `interpose-bun.js` and `interpose-sqlite.js` through the same check closure.
54
+ Arguments are analyzed only on the active path. Direct
55
+ argv becomes shell-quoted command text; a shell invocation uses the text the
56
+ shell actually receives. Requested path spelling is preserved for policy;
57
+ execution still resolves relative paths against the kernel cwd.
58
+
59
+ Each worker owns four bounded shared-memory slots. A generation-tagged CAS
60
+ claims each transition. The host claims WRITING before decoding the request or
61
+ calling the decider, so the requester cannot rewrite bytes the host is reading.
62
+ A timed-out WRITING slot remains host-owned, while unclaimed and interrupted
63
+ requests become ABANDONED. A stale interrupt cannot affect a later generation.
64
+ Checks have a 2000 ms worker-side deadline and fail closed on
65
+ transport, sizing, decoding or authority errors.
66
+
67
+ Authority follows `AsyncLocalStorage` rather than a mutable current-cell global.
68
+ No-token operations pass only when every bound token is alive and inactive;
69
+ idle code consults the kernel word. Settlement kills active tokens, but leaves
70
+ inactive continuations alive without retaining them in the host registry.
71
+ An interrupt or timeout releases tokens the same way. Cancellation still
72
+ kills the invocation token. A dead kernel word overrides
73
+ every token, including inactive tokens from settled runs. A token's death
74
+ takes precedence over its inactive bit.
75
+
76
+ Cell-created global Workers use a private MessagePort proxy. The host constructs
77
+ the instrumented worker and observes its exit directly, including nested
78
+ workers. The proxy forwards messages and transfer lists; each child gets its
79
+ own slot pool and the parent's shared authority. Retirement waits for observed
80
+ exit, not merely a termination request or a DEAD word. Inactive Workers use the
81
+ kernel lifetime, so later cells can use them or create them from a continuation.
82
+ User workerData is restored before user imports and preloads. User frames,
83
+ including primitives and objects resembling transport frames, remain data;
84
+ internal transfer envelopes carry a per-Worker key. MessagePorts transferred
85
+ through a raw `parentPort` frame are transferred on to the cell; a frame the
86
+ host cannot forward becomes a `messageerror` event and never throws in the
87
+ host listener. Error events carry the error
88
+ and its available source coordinates, and observed exit dispatches close.
89
+
90
+ ## Scope boundary
91
+
92
+ PR 3 extends the operation table without changing authority or slot messages.
93
+ Resource tables retain each open's path and access modes; locally granted
94
+ operations still check authority liveness before using a resource. Engine
95
+ package-resolution reads and process retirement use one-time, closure-private
96
+ originals, rather than gaining an exemption for an installation path.
97
+
98
+ The module plugin observes resolution only and lets the native loader handle
99
+ attributes. Cell import and require routes also check relative and absolute
100
+ paths. Dynamic-code preflight runs in the host AST preparer so workers remain
101
+ dependency-free and direct eval retains lexical scope.
102
+
103
+ Native capability residual: Bun 1.4.4 makes both the global `Bun` binding and `Bun.plugin`
104
+ non-writable and non-configurable. The cell-local binding can guard plugin calls;
105
+ native libraries' plugin calls and non-literal dynamic evaluation remain available.
106
+ The native-loader regression matrix verifies that code reached through these routes
107
+ still encounters the enforced synchronous/asynchronous spawn and write checks.
108
+ The engine adds no onLoad transform. Final acceptance of this named residual
109
+ remains with the lead.
110
+ This core is not a sandbox and does not claim read completeness.
111
+
112
+ 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.
113
+
114
+ 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.