@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
@@ -3,6 +3,8 @@ import type { BridgeConnectionConfig, KernelToHostMessage } from "../../bridge/p
3
3
  import type { EvalKernelRunInput } from "../../tool/types.ts";
4
4
  import type { SessionEnvironment } from "../session-env.ts";
5
5
  import type { KernelLifecycle } from "../shared/kernel-death.ts";
6
+ import type { InvocationPermissions } from "../shared/invocation-authority.ts";
7
+ import type { CellDecider, RevocationFailedEvent } from "../js/kernel-contract.ts";
6
8
  import type { PeerKernelToolsDescribe } from "./kernel-tools-host.ts";
7
9
  import type { KernelSpawnProcess, KillProcessGroup } from "./process.ts";
8
10
  import type { PythonStartupStage } from "./startup.ts";
@@ -12,6 +14,8 @@ export interface PythonKernelStartOptions extends KernelLifecycle {
12
14
  readonly interpreterPath: string;
13
15
  readonly sessionId: string;
14
16
  readonly cwd: string;
17
+ readonly permissions?: InvocationPermissions & { readonly decide?: CellDecider };
18
+ readonly onRevocationFailed?: (event: RevocationFailedEvent) => void;
15
19
  readonly connection: BridgeConnectionConfig;
16
20
  readonly env?: NodeJS.ProcessEnv;
17
21
  /** Per-session PI_* values merged into the interpreter environment at spawn. */
@@ -37,8 +37,12 @@ export class PythonKernelTools {
37
37
  readonly post: (message: HostToKernelMessage) => void;
38
38
  readonly isOpen: () => boolean;
39
39
  readonly peerDescribe?: PeerKernelToolsDescribe;
40
+ readonly invocation?: (
41
+ request: KernelToolsInvokeRequest,
42
+ options?: AbortSignal | KernelToolsInvokeOptions,
43
+ ) => () => void;
40
44
  }) {
41
- this.#pump = new KernelToolHostPump(options.post, options.isOpen);
45
+ this.#pump = new KernelToolHostPump(options.post, options.isOpen, options.invocation);
42
46
  this.#peerDescribe = options.peerDescribe;
43
47
  }
44
48
 
@@ -6,6 +6,7 @@ import type {
6
6
  KernelToolsInvokeRequest,
7
7
  } from "../js/kernel-tools-types.ts";
8
8
  import { inputAtStart } from "../shared/cell-source-at-start.ts";
9
+ import { invocationSession, registerInvocationSession } from "../shared/invocation-authority.ts";
9
10
  import { KernelMemoryHost } from "../shared/kernel-memory-host.ts";
10
11
  import { KernelPreludeTracker } from "../shared/kernel-prelude-plan.ts";
11
12
  import { pythonDeathReason } from "./death-reason.ts";
@@ -14,6 +15,7 @@ import type { PendingRun, PythonKernelRunOptions, PythonKernelStartOptions, Resu
14
15
  import { PythonKernelTools } from "./kernel-tools-host.ts";
15
16
  import { pythonStartupCeilingMs, pythonStartupHangGuardMs } from "./startup.ts";
16
17
  import { failedPythonResult, PythonKernelTransport } from "./transport.ts";
18
+ import { PythonPermissionHost } from "./permission-host.ts";
17
19
 
18
20
  export type { PythonKernelRunOptions, PythonKernelStartOptions } from "./kernel-contract.ts";
19
21
  export type { KernelChild, KernelSpawnOptions, KernelSpawnProcess } from "./process.ts";
@@ -47,21 +49,39 @@ export class PythonKernel {
47
49
  #closed = false;
48
50
  readonly #tools: PythonKernelTools;
49
51
  #dead = false;
52
+ readonly #releaseSession: () => void;
53
+ readonly #permission: PythonPermissionHost;
50
54
 
51
55
  private constructor(options: PythonKernelStartOptions) {
52
56
  this.#options = options;
57
+ this.#releaseSession = registerInvocationSession(options.sessionId, () => ({
58
+ cwd: options.cwd,
59
+ state: options.permissions?.state() ?? { epoch: 0, active: false, rules: [] },
60
+ }));
53
61
  this.#memory = options.memory === undefined ? null : new KernelMemoryHost("py", options.memory);
62
+ this.#permission = new PythonPermissionHost(options, async () => {
63
+ this.#tools.retire("kernel_tool_stale", "Python permission authority revoked");
64
+ this.#settleAllPending("Python permission authority revoked");
65
+ if (this.#transport) await this.#beginRetirement(this.#transport);
66
+ });
54
67
  this.#tools = new PythonKernelTools({
55
68
  post: (message) => this.#transport?.post(message),
56
69
  isOpen: () => this.#transport !== null && !this.#closed,
70
+ invocation: (request, invokeOptions) => this.#permission.invocation(request, invokeOptions),
57
71
  ...(options.peerKernelToolsDescribe === undefined ? {} : { peerDescribe: options.peerKernelToolsDescribe }),
58
72
  });
59
73
  }
60
74
 
61
75
  static async start(options: PythonKernelStartOptions): Promise<PythonKernel> {
62
76
  const kernel = new PythonKernel(options);
63
- await kernel.#spawn(kernel.#generation);
64
- return kernel;
77
+ try {
78
+ await kernel.#spawn(kernel.#generation);
79
+ return kernel;
80
+ } catch (error) {
81
+ kernel.#releaseSession();
82
+ kernel.#permission.dispose();
83
+ throw error;
84
+ }
65
85
  }
66
86
 
67
87
  listKernelToolNames(): readonly string[] {
@@ -88,11 +108,21 @@ export class PythonKernel {
88
108
  return this.#tools.describe(names);
89
109
  }
90
110
 
91
- invokeKernelTool(
111
+ async invokeKernelTool(
92
112
  request: KernelToolsInvokeRequest,
93
113
  options?: AbortSignal | KernelToolsInvokeOptions,
94
114
  ): Promise<unknown> {
95
- return this.#tools.invoke(request, options);
115
+ await this.#permission.admit();
116
+ invocationSession(
117
+ this.#options.sessionId,
118
+ {
119
+ cwd: this.#options.cwd,
120
+ state: this.#options.permissions?.state() ?? { epoch: 0, active: false, rules: [] },
121
+ },
122
+ options,
123
+ this.#options.permissions?.resolveSession,
124
+ );
125
+ return await this.#tools.invoke(request, options);
96
126
  }
97
127
 
98
128
  run(input: PythonKernelRunOptions): Promise<ResultMessage> {
@@ -159,6 +189,7 @@ export class PythonKernel {
159
189
  async reset(): Promise<void> {
160
190
  if (this.#failure) throw this.#failure;
161
191
  if (this.#closed) throw new Error("Python kernel is closed");
192
+ this.#permission.revoke();
162
193
  const generation = ++this.#generation;
163
194
  this.#tools.retire("kernel_tool_stale", "Python kernel was reset; its kernel tools are gone");
164
195
  const prior = this.#starting;
@@ -184,7 +215,9 @@ export class PythonKernel {
184
215
 
185
216
  async close(): Promise<void> {
186
217
  if (this.#closePromise) return await this.#closePromise;
218
+ this.#permission.revoke();
187
219
  this.#closed = true;
220
+ this.#releaseSession();
188
221
  this.#generation += 1;
189
222
  this.#tools.retire("tools_unavailable", "Python kernel is closed");
190
223
  this.#settleAllPending("Python kernel closed");
@@ -193,7 +226,10 @@ export class PythonKernel {
193
226
  await starting?.catch(() => undefined);
194
227
  await this.#retirement?.catch(() => undefined);
195
228
  const transport = this.#transport;
196
- if (!transport) return;
229
+ if (!transport) {
230
+ this.#permission.dispose();
231
+ return;
232
+ }
197
233
  try {
198
234
  await transport.close();
199
235
  } catch (error) {
@@ -201,6 +237,7 @@ export class PythonKernel {
201
237
  throw error;
202
238
  }
203
239
  if (this.#transport === transport) this.#transport = null;
240
+ this.#permission.dispose();
204
241
  })();
205
242
  return await this.#closePromise;
206
243
  }
@@ -228,6 +265,7 @@ export class PythonKernel {
228
265
  }
229
266
 
230
267
  async #activateNext(): Promise<void> {
268
+ await this.#permission.admit();
231
269
  await this.#ensureStarted();
232
270
  if (this.#closed || this.#active || this.#retirement) return;
233
271
  const pending = this.#queue.shift();
@@ -318,6 +356,7 @@ export class PythonKernel {
318
356
  this.#lastMemory = undefined;
319
357
  this.#transport = await PythonKernelTransport.start({
320
358
  ...this.#options,
359
+ permission: this.#permission,
321
360
  onMessage: (message) => {
322
361
  if (message.type === "result") return;
323
362
  if (generation === this.#generation && this.#tools.consume(message)) return;
@@ -445,6 +484,7 @@ export class PythonKernel {
445
484
  }
446
485
 
447
486
  #removePending(pending: PendingRun): void {
487
+ this.#permission.settle(pending.input.cellId);
448
488
  pending.hostAbort?.abort();
449
489
  if (pending.timeoutTimer) clearTimeout(pending.timeoutTimer);
450
490
  if (pending.escalationTimer) clearTimeout(pending.escalationTimer);
@@ -0,0 +1,162 @@
1
+ """Versioned twin of shared/path-identity.mjs, with captured native primitives."""
2
+ import builtins
3
+ import errno
4
+ import os
5
+ import sys
6
+ import unicodedata
7
+
8
+ __builtins__ = vars(builtins).copy()
9
+ PATH_IDENTITY_VERSION = 1
10
+
11
+
12
+ class IdentityError(OSError):
13
+ def __init__(self, code):
14
+ super().__init__(code)
15
+ self.code = code
16
+
17
+
18
+ def create_identity(runtime):
19
+ # Python's realpath is a component walker, not the native canonicalizer used
20
+ # by Node. In particular it retains requested case on case-insensitive disks.
21
+ platform = sys.platform
22
+ lstat, readlink = os.lstat, os.readlink
23
+ normalize, lower, upper = unicodedata.normalize, str.lower, str.upper
24
+ codes = dict(errno.errorcode)
25
+ fold_default = platform in ("darwin", "win32")
26
+ folds = {}
27
+
28
+ def failure(code):
29
+ return IdentityError(code)
30
+
31
+ def code_of(error):
32
+ number = getattr(error, "errno", None)
33
+ return getattr(error, "code", codes.get(number, "EUNKNOWN") if isinstance(number, int) else "EUNKNOWN")
34
+
35
+ def absolute(value):
36
+ return value.startswith("/")
37
+
38
+ def anchor(value):
39
+ if not absolute(value):
40
+ raise failure("ERELATIVE")
41
+ return value
42
+
43
+ def dirname(value):
44
+ # node:path.posix.dirname retains the separator preceding the last name.
45
+ end = len(value.rstrip("/"))
46
+ index = value.rfind("/", 0, end)
47
+ if index < 0:
48
+ return "."
49
+ return "/" if index == 0 else value[:index]
50
+
51
+ def basename(value):
52
+ return value.rstrip("/").rsplit("/", 1)[-1]
53
+
54
+ def join(*parts):
55
+ value = "/".join(part for part in parts if part)
56
+ names = []
57
+ for part in value.split("/"):
58
+ if part in ("", "."):
59
+ continue
60
+ if part == ".." and names and names[-1] != "..":
61
+ names.pop()
62
+ elif part != ".." or not value.startswith("/"):
63
+ names.append(part)
64
+ result = ("/" if value.startswith("/") else "") + "/".join(names)
65
+ return (result or ".") + ("/" if value.endswith("/") and result not in ("", "/") else "")
66
+
67
+ asset = os.path.join(os.path.dirname(__file__),
68
+ "path_identity_windows.py" if platform == "win32" else "path_identity_posix.py")
69
+ namespace = {"__builtins__": __builtins__}
70
+ try:
71
+ with open(asset, "rb") as source:
72
+ exec(compile(source.read(), asset, "exec"), namespace)
73
+ if platform == "win32":
74
+ realpath, anchor, dirname, basename, join, absolute = namespace["windows_primitives"](failure)
75
+ else:
76
+ realpath = namespace["native_realpath"](runtime, failure)
77
+ except (ImportError, OSError, AttributeError):
78
+ # No native primitives (e.g. a Python built without ctypes): send no canonical, so the host
79
+ # falls back exactly as it does for any request without one, and the kernel still starts.
80
+ return lambda executed: {}
81
+
82
+ def volume_fold(existing):
83
+ try:
84
+ device = lstat(existing).st_dev
85
+ except OSError:
86
+ return fold_default
87
+ if device in folds:
88
+ return folds[device]
89
+ probe = existing
90
+ while True:
91
+ chars = list(basename(probe))
92
+ index = next((i for i, char in enumerate(chars) if lower(char) != upper(char)), None)
93
+ if index is not None:
94
+ char = chars[index]
95
+ chars[index] = upper(char) if char == lower(char) else lower(char)
96
+ try:
97
+ original = lstat(probe)
98
+ variant = lstat(join(dirname(probe), "".join(chars)))
99
+ answer = (original.st_dev, original.st_ino) == (variant.st_dev, variant.st_ino)
100
+ except OSError:
101
+ answer = False
102
+ break
103
+ parent = dirname(probe)
104
+ if parent == probe:
105
+ answer = fold_default
106
+ break
107
+ probe = parent
108
+ try:
109
+ same = lstat(probe).st_dev == device
110
+ except OSError:
111
+ same = False
112
+ if not same:
113
+ answer = fold_default
114
+ break
115
+ folds[device] = answer
116
+ return answer
117
+
118
+ def canonical(executed):
119
+ existing, missing, hops, followed = anchor(executed), [], 0, False
120
+ while True:
121
+ try:
122
+ real = realpath(existing)
123
+ except OSError as error:
124
+ code = code_of(error)
125
+ if code not in ("ENOENT", "ENOTDIR"):
126
+ raise failure(code)
127
+ try:
128
+ info = lstat(existing)
129
+ except OSError as inner:
130
+ if code_of(inner) not in ("ENOENT", "ENOTDIR"):
131
+ raise failure(code_of(inner))
132
+ info = None
133
+ if info is not None and info.st_mode & 0o170000 == 0o120000:
134
+ hops += 1
135
+ if hops > 40:
136
+ raise failure("ELOOP")
137
+ target = readlink(existing)
138
+ existing = target if absolute(target) else join(dirname(existing), target)
139
+ followed = True
140
+ continue
141
+ if followed and missing:
142
+ raise failure("EDANGLING")
143
+ missing.insert(0, basename(existing))
144
+ parent = dirname(existing)
145
+ if parent == existing:
146
+ raise failure(code)
147
+ existing = parent
148
+ continue
149
+ path = join(real, *missing) if missing else real
150
+ return {"v": PATH_IDENTITY_VERSION, "path": normalize("NFC", path) if platform == "darwin" else path,
151
+ "foldCase": volume_fold(real)}
152
+
153
+ def identity(executed):
154
+ try:
155
+ return {"canonical": canonical(executed)}
156
+ except UnicodeError:
157
+ # A native filename that cannot travel as UTF-8 has no exact wire form.
158
+ return {}
159
+ except OSError as error:
160
+ code = code_of(error)
161
+ return {} if code == "EIDENTITY_UNSUPPORTED" else {"unresolvable": code}
162
+ return identity
@@ -0,0 +1,74 @@
1
+ """Native primitives used by Node/libuv and Bun's POSIX realpath."""
2
+ import ctypes
3
+ import errno
4
+ import os
5
+ import sys
6
+
7
+
8
+ def native_realpath(runtime, failure):
9
+ library = ctypes.CDLL(None, use_errno=True)
10
+ native = library.realpath
11
+ native.argtypes = [ctypes.c_char_p, ctypes.c_void_p]
12
+ native.restype = ctypes.c_void_p
13
+ free = library.free
14
+ free.argtypes = [ctypes.c_void_p]
15
+ free.restype = None
16
+ string_at, get_errno = ctypes.string_at, ctypes.get_errno
17
+ codes = dict(errno.errorcode)
18
+
19
+ def realpath(value):
20
+ if "\0" in value:
21
+ raise failure("ERR_INVALID_ARG_VALUE")
22
+ pointer = native(value.encode("utf-8"), None)
23
+ if not pointer:
24
+ raise failure(codes.get(get_errno(), "EUNKNOWN"))
25
+ try:
26
+ return string_at(pointer).decode("utf-8", "strict")
27
+ finally:
28
+ free(pointer)
29
+
30
+ if runtime != "bun":
31
+ return realpath
32
+ native_open, native_close, fstat, readlink = os.open, os.close, os.fstat, os.readlink
33
+ if sys.platform == "darwin":
34
+ # bun-v1.4.4/src/runtime/node/node_fs.rs: open + F_GETPATH.
35
+ fcntl = library.fcntl
36
+ # Variadic: Darwin arm64 passes the third argument on the stack.
37
+ fcntl.argtypes = [ctypes.c_int, ctypes.c_int]
38
+ fcntl.restype = ctypes.c_int
39
+ buffer = ctypes.create_string_buffer
40
+ open_flags = os.O_RDONLY | os.O_NONBLOCK | os.O_NOCTTY
41
+
42
+ def realpath(value):
43
+ if "\0" in value:
44
+ raise failure("ERR_INVALID_ARG_VALUE")
45
+ fd = native_open(value, open_flags)
46
+ try:
47
+ result = buffer(1024)
48
+ if fcntl(fd, 50, result) == -1: # Darwin F_GETPATH
49
+ code = codes.get(get_errno(), "EUNKNOWN")
50
+ raise failure("ENAMETOOLONG" if code == "ENOSPC" else code)
51
+ return result.value.decode("utf-8", "strict")
52
+ finally:
53
+ native_close(fd)
54
+ return realpath
55
+ if sys.platform == "linux":
56
+ path_only = os.O_PATH
57
+
58
+ def realpath(value):
59
+ if "\0" in value:
60
+ raise failure("ERR_INVALID_ARG_VALUE")
61
+ fd = native_open(value, path_only)
62
+ try:
63
+ result = readlink(f"/proc/self/fd/{fd}")
64
+ if result.endswith(" (deleted)") and fstat(fd).st_nlink == 0:
65
+ raise failure("ENOENT")
66
+ result.encode("utf-8", "strict")
67
+ return result
68
+ finally:
69
+ native_close(fd)
70
+ return realpath
71
+
72
+ def unsupported(value):
73
+ raise failure("EIDENTITY_UNSUPPORTED")
74
+ return unsupported
@@ -0,0 +1,93 @@
1
+ """Captured Win32 primitives matching libuv's fs__realpath_handle."""
2
+ import ctypes
3
+ import ntpath
4
+ import os
5
+ import types
6
+
7
+
8
+ def windows_primitives(failure):
9
+ library = ctypes.WinDLL("kernel32", use_last_error=True)
10
+ create = library.CreateFileW
11
+ create.argtypes = [ctypes.c_wchar_p, ctypes.c_uint32, ctypes.c_uint32, ctypes.c_void_p,
12
+ ctypes.c_uint32, ctypes.c_uint32, ctypes.c_void_p]
13
+ create.restype = ctypes.c_void_p
14
+ final = library.GetFinalPathNameByHandleW
15
+ final.argtypes = [ctypes.c_void_p, ctypes.c_wchar_p, ctypes.c_uint32, ctypes.c_uint32]
16
+ final.restype = ctypes.c_uint32
17
+ close = library.CloseHandle
18
+ close.argtypes = [ctypes.c_void_p]
19
+ close.restype = ctypes.c_int
20
+ invalid = ctypes.c_void_p(-1).value
21
+ get_error, buffer = ctypes.get_last_error, ctypes.create_unicode_buffer
22
+ # These pure lexical helpers share a private globals dictionary, not mutable
23
+ # ntpath/os module attributes a cell could replace.
24
+ namespace = {**vars(ntpath), "__builtins__": __builtins__,
25
+ "os": types.SimpleNamespace(fspath=os.fspath, getcwd=os.getcwd)}
26
+ for name, function in tuple(namespace.items()):
27
+ if isinstance(function, types.FunctionType) and function.__globals__ is vars(ntpath):
28
+ namespace[name] = types.FunctionType(function.__code__, namespace, name, function.__defaults__)
29
+ dirname, basename, normpath, splitdrive = (
30
+ types.FunctionType(getattr(ntpath, name).__code__, namespace, name)
31
+ for name in ("dirname", "basename", "normpath", "splitdrive")
32
+ )
33
+ getcwd = os.getcwd
34
+
35
+ def join(*parts):
36
+ # node:path.join concatenates before normalizing; ntpath.join instead
37
+ # discards earlier components when a later component contains a drive.
38
+ value = "\\".join(part for part in parts if part)
39
+ result = normpath(value)
40
+ if value.endswith(("\\", "/")) and not result.endswith("\\"):
41
+ result += "\\"
42
+ return result
43
+
44
+ def anchor(value):
45
+ value = value.replace("/", "\\")
46
+ # Drive-relative paths use per-drive process state in node:path.resolve;
47
+ # device namespaces have different lexical rules. Neither is approximated.
48
+ drive, tail = splitdrive(value)
49
+ if drive.startswith(("\\\\?", "\\\\.")):
50
+ raise failure("EIDENTITY_UNSUPPORTED")
51
+ if not drive and tail.startswith("\\"):
52
+ drive = splitdrive(getcwd())[0]
53
+ if drive.startswith("\\\\") and not tail:
54
+ tail = "\\"
55
+ if not drive or not tail.startswith("\\"):
56
+ raise failure("EIDENTITY_UNSUPPORTED")
57
+ value = normpath(drive + tail)
58
+ return "\\".join(part if i == 0 else part.rstrip(". ") for i, part in enumerate(value.split("\\")))
59
+
60
+ def native_error():
61
+ code = get_error()
62
+ # Known libuv translations. Other Win32 failures carry no identity
63
+ # instead of assuming Python's errno translation is the same.
64
+ return failure({2: "ENOENT", 3: "ENOENT", 267: "ENOTDIR"}.get(code, "EIDENTITY_UNSUPPORTED"))
65
+
66
+ def realpath(value):
67
+ if "\0" in value:
68
+ raise failure("ERR_INVALID_ARG_VALUE")
69
+ handle = create(value, 0, 0, None, 3, 0x80 | 0x02000000, None)
70
+ if handle == invalid:
71
+ raise native_error()
72
+ try:
73
+ size = final(handle, None, 0, 0)
74
+ if not size:
75
+ raise native_error()
76
+ result = buffer(size + 1)
77
+ length = final(handle, result, size, 0)
78
+ if not length or length >= size:
79
+ raise failure("EIDENTITY_UNSUPPORTED")
80
+ value = result.value
81
+ if value.startswith("\\\\?\\UNC\\"):
82
+ return "\\\\" + value[8:]
83
+ if value.startswith("\\\\?\\"):
84
+ return value[4:]
85
+ raise failure("EIDENTITY_UNSUPPORTED")
86
+ finally:
87
+ close(handle)
88
+
89
+ def absolute(value):
90
+ drive, tail = splitdrive(value)
91
+ return bool(tail.startswith(("\\", "/")) or drive.startswith("\\\\"))
92
+
93
+ return realpath, anchor, dirname, basename, join, absolute
@@ -0,0 +1,103 @@
1
+ # Python kernel permissions
2
+
3
+ This is the Python portion of senpi#4309, stacked on the JavaScript permission
4
+ core and invoker-authority work. The integrating permission extension supplies
5
+ `PythonKernelStartOptions.permissions.state` and `decide`. This PR does not
6
+ replace the extension's rule matcher or connect configuration reload events.
7
+
8
+ ## Transport and authority
9
+
10
+ Each cell and kernel-tool invocation receives a fresh host-minted token.
11
+ Python keeps that token in a `ContextVar`, including copied parallel contexts.
12
+ The host's existing invocation pump owns callback token lifetime; the existing
13
+ cross-session admission check runs before the callback body.
14
+
15
+ The existing loopback HTTP bridge serves `/permission_check`. This route uses
16
+ the invocation capability, not the owner's bridge bearer. Every operation opens
17
+ a new connection, sends a random nonce, and requires the same nonce in the reply.
18
+ Connect, send, and receive share one two-second deadline. Unknown tokens,
19
+ transport failures, invalid replies, host failures, cancellation, and revoked
20
+ tokens all refuse. The ordinary authenticated bridge routes are unchanged.
21
+ The shared `CellOperation` type adds `read` and `ls`; the JavaScript slot layout
22
+ and its state transitions are unchanged.
23
+
24
+ Python maps a host-owned authority file read-only. The host publishes each
25
+ epoch/alive/active word with one aligned eight-byte write, and never reuses a
26
+ token offset while its interpreter may retain a context. Wrapped operations
27
+ inspect the word before taking the inactive fast path and again after an allow.
28
+ No-token contexts consult the kernel word and cannot borrow an enforcing
29
+ invocation's authority.
30
+ An expired inactive token may continue ordinary background work only while the
31
+ kernel word remains alive and inactive. An expired enforcing token, or any dead
32
+ kernel word, still refuses. This keeps copied contexts and asyncio tasks working
33
+ across cells in sessions without deny rules.
34
+
35
+ `revokeSessionKernels` marks the words DEAD synchronously and cancels pending
36
+ checks before requesting retirement. Admission waits for the existing process
37
+ retirement path to observe exit. An abandoned retirement permanently fences
38
+ the host session and emits `onRevocationFailed`; the integrating extension must
39
+ also fence direct tools on that event.
40
+
41
+ ## Runtime boundary
42
+
43
+ The prelude loads both permission modules by their own file locations before
44
+ user code, including when the prelude is loaded through `runpy`. Captured
45
+ originals, the descriptor grants, and the mapping stay in closures. Wrappers
46
+ do not publish `__wrapped__` references to originals.
47
+ Permission decisions use private copies of classification helpers, constants,
48
+ builtins, socket methods, and JSON codecs. Python helpers whose globals contain
49
+ dependencies get private globals dictionaries too. Rebinding public stdlib names
50
+ cannot change an authority word, forge a reply, or redirect a permission target.
51
+ The `posix`/`nt` aliases share the same wrappers as `os`.
52
+
53
+ Every path request carries `path`, the spelling as written, and
54
+ `executedPath`, the string the operating system receives (a relative spelling
55
+ joined onto the interpreter's current directory with `os.path.join`, never
56
+ lexically collapsed). `path_identity.py` mirrors the JavaScript kernel's
57
+ version-1 canonicalizer: native resolution of the deepest existing ancestor,
58
+ the same missing-tail and dangling-link rules, Darwin NFC normalization, and
59
+ a per-device case-folding probe. It sends `canonical: {v, path, foldCase}` or
60
+ the same `unresolvable` error code. The bridge validates the field types; the
61
+ permission service retains the shared version check and mount rewrite.
62
+ The current canonical keeps identity matching available when the host resolver
63
+ cannot start, just as for JavaScript cells.
64
+
65
+ Native primitives are captured before interposition. Node uses libc realpath
66
+ on POSIX; Bun uses descriptor paths on Darwin and Linux. Windows uses libuv's
67
+ CreateFileW/GetFinalPathNameByHandleW sequence. Drive-relative and device-namespace
68
+ Windows paths, unclassified Win32 errors, unsupported Bun platforms, and native
69
+ names that cannot be represented losslessly in UTF-8 omit canonical identity
70
+ rather than sending an approximation. They retain the existing host fallback;
71
+ no operation is refused merely because local canonical identity is unavailable.
72
+ An input spelling that is not valid UTF-8 remains uncheckable under the existing
73
+ wire contract.
74
+
75
+ File modes map to read, write, or both. Descriptor grants record paths and
76
+ modes; unknown descriptors and opposite-mode operations refuse. Retained file
77
+ objects check authority on use. SQLite connections compose their authorizer
78
+ with user authorizers, refuse ATTACH and extension loading, and separately
79
+ decide both backup endpoints.
80
+
81
+ Loader source and resource reads remain subject to read checks. The engine's
82
+ bytecode-cache writer is recognized by the original loader code objects in
83
+ its call chain, not by a runtime-directory prefix. Direct calls to `set_data`
84
+ still request write permission. Memory probes resolve before interposition and
85
+ keep their fixed engine I/O private. Under enforcement, exception formatting
86
+ reports the real traceback frames without issuing linecache source-file reads.
87
+ Engine permission assets are compiled directly from source without a bytecode
88
+ cache. Dynamic and builtin extension entry points, including `_imp`, are guarded.
89
+ Cells see live mapping/sequence views of import registries while enforcing:
90
+ ordinary reads continue, ordinary mutations carry the permission refusal, and
91
+ direct `dict`/`list` descriptors reject the views with `TypeError` before any
92
+ mutation. The `sys` dictionary view is read-only in that mode.
93
+ FileIO remains a subclassable raw-I/O adapter, but its MRO no longer exposes the
94
+ unguarded native file constructor.
95
+
96
+ Ruby and Julia refuse each enforcing cell before posting it to the interpreter,
97
+ including queued cells whose policy changed before execution. Inactive cells
98
+ retain their existing execution path.
99
+
100
+ This is API interposition, not a sandbox against deliberate introspection or
101
+ already-loaded native code.
102
+
103
+ 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.